From c00a2d30b5cf720da835a456c2e5b61908f48955 Mon Sep 17 00:00:00 2001 From: Jared Therriault Date: Tue, 30 Jun 2026 17:19:53 -0700 Subject: [PATCH] Add metadata viewer editing function for images - now you can change field names and values in metadata. No add or delete functions yet. --- __init__.py | 4 + py/server_backend.py | 143 +++++++++++++++++- web/js/ImageDrawer/Contexts.js | 134 +++++++++++++++- .../ImageListChildElements/ExtraNetworks.js | 7 +- .../ImageListChildElementUtils.js | 7 +- 5 files changed, 284 insertions(+), 11 deletions(-) diff --git a/__init__.py b/__init__.py index 067946b..3e97a85 100644 --- a/__init__.py +++ b/__init__.py @@ -102,6 +102,10 @@ async def delete_item_wrapper(request): def get_browser_video_extensions_wrapper(request): from .py.utils import ACCEPTED_BROWSER_VIDEO_EXTENSIONS return web.json_response({"success": True, "extensions": ACCEPTED_BROWSER_VIDEO_EXTENSIONS}) + +@server.PromptServer.instance.routes.post('/jnodes_edit_image_metadata') +async def edit_image_metadata_wrapper(request): + return await edit_image_metadata(request) WEB_DIRECTORY = "./web" diff --git a/py/server_backend.py b/py/server_backend.py index dfecf7b..84de550 100644 --- a/py/server_backend.py +++ b/py/server_backend.py @@ -11,7 +11,12 @@ import aiohttp from aiohttp import web import server -from PIL import Image +from PIL import Image, ImageSequence +from PIL.PngImagePlugin import PngInfo +from io import BytesIO + +import piexif +import piexif.helper import json import shutil @@ -849,4 +854,138 @@ async def delete_item(request): pass logger.warning("File could not be deleted: file not found") - return web.Response(status=404) \ No newline at end of file + return web.Response(status=404) + +def _strip_surrogates(obj): + if isinstance(obj, str): + return re.sub(r'[\ud800-\udfff]', '', obj) + if isinstance(obj, dict): + return {k: _strip_surrogates(v) for k, v in obj.items()} + if isinstance(obj, list): + return [_strip_surrogates(item) for item in obj] + return obj + +async def edit_image_metadata(request): + try: + result = await validate_and_return_file_from_request(request) + + if result["success"] != True: + return web.json_response({"success": False, "error": "File not found"}) + + file_path = result["payload"]["file"] + request_data = await read_web_request_content(request.content) + request_json = json.loads(request_data) + + metadata = _strip_surrogates(request_json.get("metadata", {})) + image_format = request_json.get("format", "") + + ext = os.path.splitext(file_path)[1].lower() + + if ext == ".png": + with Image.open(file_path) as img: + pnginfo = PngInfo() + for k, v in metadata.items(): + value = v + if isinstance(v, str) and (v.startswith("{") or v.startswith("[")): + try: + value = json.loads(v) + except: + pass + if isinstance(value, (dict, list)): + text = json.dumps(value, ensure_ascii=True) + else: + text = str(value) + pnginfo.add_text(k, text) + img.save(file_path, pnginfo=pnginfo) + + elif ext in (".jpg", ".jpeg"): + with Image.open(file_path) as img: + existing_metadata = {} + try: + exif_data = piexif.load(file_path) + user_comment = exif_data.get("Exif", {}).get(piexif.ExifIFD.UserComment) + if user_comment: + decoded = piexif.helper.UserComment.decode(user_comment) + if decoded.startswith("UNICODE"): + decoded = decoded[7:] + existing_metadata = json.loads(decoded) + except: + pass + + existing_metadata.update(metadata) + + exif_dict = {"0th": {}, "Exif": {}, "GPS": {}, "1st": {}, "thumbnail": None} + try: + old_exif = piexif.load(file_path) + exif_dict["0th"] = old_exif.get("0th", {}) + exif_dict["GPS"] = old_exif.get("GPS", {}) + exif_dict["1st"] = old_exif.get("1st", {}) + except: + pass + exif_dict["Exif"][piexif.ExifIFD.UserComment] = json.dumps(existing_metadata).encode() + exif_bytes = piexif.dump(exif_dict) + img.save(file_path, exif=exif_bytes, quality=95) + + elif ext == ".webp": + with Image.open(file_path) as img: + existing_metadata = {} + try: + exif_data = piexif.load(file_path) + user_comment = exif_data.get("Exif", {}).get(piexif.ExifIFD.UserComment) + if user_comment: + decoded = piexif.helper.UserComment.decode(user_comment) + if decoded.startswith("UNICODE"): + decoded = decoded[7:] + existing_metadata = json.loads(decoded) + except: + pass + + existing_metadata.update(metadata) + + exif_bytes = piexif.dump({ + "Exif": { + piexif.ExifIFD.UserComment: piexif.helper.UserComment.dump( + json.dumps(existing_metadata, indent=2, sort_keys=True), encoding="unicode" + ) + } + }) + is_animated = getattr(img, "is_animated", False) + if is_animated: + img.save(file_path, format="WEBP", exif=exif_bytes, save_all=True) + else: + img.save(file_path, format="WEBP", lossless=True, exif=exif_bytes) + + elif ext == ".gif": + with Image.open(file_path) as img: + existing_metadata = {} + try: + existing_metadata = json.loads(img.info.get("comment", "{}")) + except: + pass + existing_metadata.update(metadata) + + is_animated = getattr(img, "is_animated", False) + if is_animated: + frames = [] + durations = [] + for frame in ImageSequence.Iterator(img): + frames.append(frame.copy()) + durations.append(frame.info.get("duration", 100)) + frames[0].save( + file_path, + save_all=True, + append_images=frames[1:], + duration=durations, + loop=0, + comment=json.dumps(existing_metadata), + ) + else: + img.save(file_path, comment=json.dumps(existing_metadata)) + else: + return web.json_response({"success": False, "error": f"Unsupported image format: {ext}"}) + + return web.json_response({"success": True}) + + except Exception as e: + log_exception("Error editing image metadata:", e) + return web.json_response({"success": False, "error": str(e)}) \ No newline at end of file diff --git a/web/js/ImageDrawer/Contexts.js b/web/js/ImageDrawer/Contexts.js index 31bb512..93b3771 100644 --- a/web/js/ImageDrawer/Contexts.js +++ b/web/js/ImageDrawer/Contexts.js @@ -1304,7 +1304,8 @@ export class ContextMetadataViewer extends ImageDrawerContext { if (bIsBinary) { // file = payload.getAsFile(); - await this.setImageOrVideo(payload, true); + const fileInfo = payload.name ? { filename: payload.name } : null; + await this.setImageOrVideo(payload, true, fileInfo); } else if (payload.type == 'text/jnodes_image_drawer_payload') { // a payload specific to media from the drawer @@ -1342,7 +1343,12 @@ export class ContextMetadataViewer extends ImageDrawerContext { const blob = await response.blob(); if (blob) { - this.setImageOrVideo(blob, true); + const fileInfo = { + filename: jnodesPayload.filename, + type: jnodesPayload.type, + subfolder: jnodesPayload.subdirectory || jnodesPayload.subfolder || "", + }; + this.setImageOrVideo(blob, true, fileInfo); } // Resolve the promise with the result of updateNode() @@ -1664,6 +1670,56 @@ export class ContextMetadataViewer extends ImageDrawerContext { return row; } + _collectMetadataFromTable(tbody) { + const metadata = {}; + for (const row of tbody.children) { + const keyCell = row.children[0]; + const valueCell = row.children[1]; + if (!keyCell || !valueCell) continue; + + const keySpan = keyCell.querySelector("span"); + const valueSpan = valueCell.querySelector("span"); + if (!keySpan || !valueSpan) continue; + + const key = keySpan.textContent.trim(); + let value = valueSpan.textContent.trim(); + + try { + value = JSON.parse(value); + } catch { + // keep as string + } + + metadata[key] = value; + } + return metadata; + } + + async _handleEditMetadata(metadata) { + if (!this.currentFileInfo) { + return { success: false, error: "No file path available for saving" }; + } + + const { filename, type, subfolder } = this.currentFileInfo; + + let href = `/jnodes_edit_image_metadata?`; + if (filename) href += `filename=${encodeURIComponent(filename)}&`; + if (type) href += `type=${encodeURIComponent(type)}&`; + if (subfolder) href += `subfolder=${encodeURIComponent(subfolder)}&`; + + const response = await fetch(href, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + metadata: metadata, + format: this.viewingItem?.type || "", + }), + }); + + const result = await response.json(); + return result; + } + _makeMediaPreview(inImageOrVideo) { this.previewElement = null; @@ -1719,7 +1775,7 @@ export class ContextMetadataViewer extends ImageDrawerContext { } } - async _generateMetadataWidgetFromImageOrVideo(inImageOrVideo) { + async _generateMetadataWidgetFromImageOrVideo(inImageOrVideo, inFileInfo = null) { let metadata = null; try { metadata = await getMetaData(inImageOrVideo, inImageOrVideo.type); @@ -1775,8 +1831,9 @@ export class ContextMetadataViewer extends ImageDrawerContext { }); const bCreateCopyButton = true; - const bCreateEditButton = false; // TODO: No reason to edit until I implement saving meta back to the original image - const bCreateDeleteButton = false; + const isImageType = inImageOrVideo && inImageOrVideo.type && inImageOrVideo.type.startsWith("image/"); + const bCreateEditButton = isImageType; + const bCreateDeleteButton = isImageType; for (const [key, value] of entries) { if (key !== "parameters") { @@ -1788,6 +1845,68 @@ export class ContextMetadataViewer extends ImageDrawerContext { table.appendChild(tbody); + // Save button for image metadata edits + if (isImageType) { + const saveContainer = $el("div", { + style: { + display: "flex", + alignItems: "center", + gap: "8px", + width: "100%", + margin: "10px 0", + } + }); + + const saveButton = $el("button", { + textContent: "Save Metadata", + style: { + padding: "8px 16px", + borderRadius: "4px", + border: "1px solid #4a4", + backgroundColor: "#2a5a2a", + color: "#fff", + cursor: "pointer", + fontWeight: "bold", + } + }); + + const saveStatus = $el("span", { + style: { + color: "#aaa", + fontSize: "13px", + } + }); + + saveButton.addEventListener("click", async () => { + saveButton.disabled = true; + saveButton.textContent = "Saving..."; + saveStatus.textContent = ""; + + try { + const metadata = this._collectMetadataFromTable(tbody); + const result = await this._handleEditMetadata(metadata); + if (result.success) { + saveStatus.textContent = "Saved!"; + saveStatus.style.color = "#4a4"; + } else { + saveStatus.textContent = result.error || "Save failed"; + saveStatus.style.color = "#a44"; + } + } catch (e) { + saveStatus.textContent = `Error: ${e.message}`; + saveStatus.style.color = "#a44"; + } finally { + saveButton.disabled = false; + saveButton.textContent = "Save Metadata"; + setTimeout(() => { saveStatus.textContent = ""; }, 3000); + } + }); + + saveContainer.appendChild(saveButton); + saveContainer.appendChild(saveStatus); + container.appendChild(saveContainer); + } + // Create a container to hold input + button side-by-side const filterContainer = $el("div", { style: { @@ -1903,12 +2022,12 @@ export class ContextMetadataViewer extends ImageDrawerContext { return null; } - async setImageOrVideo(inImageOrVideoBlob, bUpdateOverlayWidget = false) { + async setImageOrVideo(inImageOrVideoBlob, bUpdateOverlayWidget = false, inFileInfo = null) { let widget; try { - widget = await this._generateMetadataWidgetFromImageOrVideo(inImageOrVideoBlob); + widget = await this._generateMetadataWidgetFromImageOrVideo(inImageOrVideoBlob, inFileInfo); } catch { // pass @@ -1917,6 +2036,7 @@ export class ContextMetadataViewer extends ImageDrawerContext { if (widget) { this.viewingItem = inImageOrVideoBlob; + this.currentFileInfo = inFileInfo; this.setContent(widget); if (bUpdateOverlayWidget) { diff --git a/web/js/ImageDrawer/ImageListChildElements/ExtraNetworks.js b/web/js/ImageDrawer/ImageListChildElements/ExtraNetworks.js index 9479aae..90c06e6 100644 --- a/web/js/ImageDrawer/ImageListChildElements/ExtraNetworks.js +++ b/web/js/ImageDrawer/ImageListChildElements/ExtraNetworks.js @@ -901,7 +901,12 @@ export async function createExtraNetworkCard(nameText, familiars, type, imageDra blob = new Blob([blob], { type: modelElement.fileInfo.file.format }); } - metadataViewerContextObject.setImageOrVideo(blob, true); + const fileInfo = { + filename: modelElement.fileInfo.filename, + type: modelElement.fileInfo.type, + subfolder: modelElement.fileInfo.subdirectory || modelElement.fileInfo.subfolder || "", + }; + metadataViewerContextObject.setImageOrVideo(blob, true, fileInfo); } } ) diff --git a/web/js/ImageDrawer/ImageListChildElements/ImageListChildElementUtils.js b/web/js/ImageDrawer/ImageListChildElements/ImageListChildElementUtils.js index 62d8f4f..74a4595 100644 --- a/web/js/ImageDrawer/ImageListChildElements/ImageListChildElementUtils.js +++ b/web/js/ImageDrawer/ImageListChildElements/ImageListChildElementUtils.js @@ -348,7 +348,12 @@ export function getOrCreateToolButton(imageElementToUse) { blob = new Blob([blob], { name: imageElementToUse.filename, type: imageElementToUse.fileInfo.file.format }); } - metadataViewerContextObject.setImageOrVideo(blob, true); + const fileInfo = { + filename: imageElementToUse.fileInfo.filename, + type: imageElementToUse.fileInfo.type, + subfolder: imageElementToUse.fileInfo.subdirectory || imageElementToUse.fileInfo.subfolder || "", + }; + metadataViewerContextObject.setImageOrVideo(blob, true, fileInfo); } } )