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:
@@ -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
@@ -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)})
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user