Add metadata viewer editing function for images - now you can change field names and values in metadata. No add or delete functions yet.

This commit is contained in:
Jared Therriault
2026-06-30 17:19:53 -07:00
parent 76cdc692a8
commit c00a2d30b5
5 changed files with 284 additions and 11 deletions
+4
View File
@@ -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"
+141 -2
View File
@@ -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)
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)})
+127 -7
View File
@@ -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) {
@@ -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);
}
}
)
@@ -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);
}
}
)