Add Obsidian EXIF prompt plugin
This commit is contained in:
@@ -0,0 +1,508 @@
|
||||
const {
|
||||
Notice,
|
||||
Plugin,
|
||||
PluginSettingTab,
|
||||
Setting,
|
||||
TFile,
|
||||
} = require("obsidian");
|
||||
|
||||
const DEFAULT_SETTINGS = {
|
||||
codeBlockLanguage: "text",
|
||||
includeMetadataKey: true,
|
||||
skipExistingPromptBlocks: true,
|
||||
};
|
||||
|
||||
const IMAGE_EMBED_RE = /!\[[^\]]*]\(([^)]+)\)|!\[\[([^|\]\n]+)(?:\|[^\]\n]*)?]]/g;
|
||||
const PROMPT_BLOCK_MARKER = "<!-- exif-prompt-extractor -->";
|
||||
|
||||
module.exports = class ExifPromptPlugin extends Plugin {
|
||||
async onload() {
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
||||
|
||||
this.addRibbonIcon("image-plus", "Extract image prompt", async () => {
|
||||
await this.extractForActiveImage();
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "extract-prompt-for-image-at-cursor",
|
||||
name: "Extract prompt for image at cursor",
|
||||
icon: "image-plus",
|
||||
hotkeys: [{ modifiers: ["Mod", "Alt"], key: "p" }],
|
||||
editorCallback: async (editor, view) => {
|
||||
await this.extractForImageAtCursor(editor, view);
|
||||
},
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "extract-prompts-for-all-images-in-note",
|
||||
name: "Extract prompts for all images in note",
|
||||
icon: "images",
|
||||
editorCallback: async (editor, view) => {
|
||||
await this.extractAllInEditor(editor, view);
|
||||
},
|
||||
});
|
||||
|
||||
this.addSettingTab(new ExifPromptSettingTab(this.app, this));
|
||||
}
|
||||
|
||||
async saveSettings() {
|
||||
await this.saveData(this.settings);
|
||||
}
|
||||
|
||||
async extractForActiveImage() {
|
||||
const view = this.app.workspace.getActiveViewOfType(require("obsidian").MarkdownView);
|
||||
if (!view) {
|
||||
new Notice("Open a Markdown note first.");
|
||||
return;
|
||||
}
|
||||
await this.extractForImageAtCursor(view.editor, view);
|
||||
}
|
||||
|
||||
async extractForImageAtCursor(editor, view) {
|
||||
const lineNumber = editor.getCursor().line;
|
||||
const match = findNearestImageEmbed(editor, lineNumber);
|
||||
if (!match) {
|
||||
new Notice("No image embed found near the cursor.");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.extractPromptFromLink(match.link, view.file);
|
||||
if (!result.prompt) {
|
||||
new Notice(result.message || "No prompt metadata found.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.settings.skipExistingPromptBlocks && hasPromptBlockAfter(editor, match.line)) {
|
||||
new Notice("Prompt block already exists below this image.");
|
||||
return;
|
||||
}
|
||||
|
||||
const block = this.formatPromptBlock(result.prompt, result.label);
|
||||
editor.replaceRange("\n" + block, { line: match.line + 1, ch: 0 });
|
||||
new Notice("Prompt inserted below the image.");
|
||||
}
|
||||
|
||||
async extractAllInEditor(editor, view) {
|
||||
const embeds = findAllImageEmbeds(editor.getValue());
|
||||
if (!embeds.length) {
|
||||
new Notice("No image embeds found in this note.");
|
||||
return;
|
||||
}
|
||||
|
||||
let inserted = 0;
|
||||
for (let index = embeds.length - 1; index >= 0; index--) {
|
||||
const embed = embeds[index];
|
||||
if (this.settings.skipExistingPromptBlocks && hasPromptBlockAfter(editor, embed.line)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = await this.extractPromptFromLink(embed.link, view.file);
|
||||
if (!result.prompt) {
|
||||
continue;
|
||||
}
|
||||
|
||||
editor.replaceRange(
|
||||
"\n" + this.formatPromptBlock(result.prompt, result.label),
|
||||
{ line: embed.line + 1, ch: 0 }
|
||||
);
|
||||
inserted++;
|
||||
}
|
||||
|
||||
new Notice(inserted ? `Inserted ${inserted} prompt block(s).` : "No new prompt metadata found.");
|
||||
}
|
||||
|
||||
async extractPromptFromLink(link, sourceFile) {
|
||||
const cleanLink = normalizeImageLink(link);
|
||||
const file = this.app.metadataCache.getFirstLinkpathDest(cleanLink, sourceFile ? sourceFile.path : "");
|
||||
|
||||
if (!(file instanceof TFile)) {
|
||||
return { prompt: null, message: `Image not found: ${cleanLink}` };
|
||||
}
|
||||
|
||||
const extension = file.extension.toLowerCase();
|
||||
if (!["png", "jpg", "jpeg", "webp"].includes(extension)) {
|
||||
return { prompt: null, message: `Unsupported image type: ${file.extension}` };
|
||||
}
|
||||
|
||||
const buffer = await this.app.vault.readBinary(file);
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const metadata = extractImageMetadata(bytes, extension);
|
||||
const picked = pickPrompt(metadata);
|
||||
|
||||
return {
|
||||
prompt: picked ? picked.value : null,
|
||||
label: picked ? picked.key : null,
|
||||
message: picked ? null : `No prompt metadata found in ${file.name}.`,
|
||||
};
|
||||
}
|
||||
|
||||
formatPromptBlock(prompt, label) {
|
||||
const language = this.settings.codeBlockLanguage.trim() || "text";
|
||||
const header = this.settings.includeMetadataKey && label ? `Prompt metadata: ${label}\n\n` : "";
|
||||
return `${PROMPT_BLOCK_MARKER}\n\`\`\`${language}\n${header}${prompt.trim()}\n\`\`\`\n`;
|
||||
}
|
||||
};
|
||||
|
||||
class ExifPromptSettingTab extends PluginSettingTab {
|
||||
constructor(app, plugin) {
|
||||
super(app, plugin);
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
display() {
|
||||
const { containerEl } = this;
|
||||
containerEl.empty();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Code block language")
|
||||
.setDesc("Language label for inserted fenced code blocks.")
|
||||
.addText((text) => text
|
||||
.setPlaceholder("text")
|
||||
.setValue(this.plugin.settings.codeBlockLanguage)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.codeBlockLanguage = value || "text";
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Include metadata key")
|
||||
.setDesc("Add the metadata field name at the top of inserted prompt blocks.")
|
||||
.addToggle((toggle) => toggle
|
||||
.setValue(this.plugin.settings.includeMetadataKey)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.includeMetadataKey = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Skip existing prompt blocks")
|
||||
.setDesc("Avoid inserting another block when this plugin already added one below the image.")
|
||||
.addToggle((toggle) => toggle
|
||||
.setValue(this.plugin.settings.skipExistingPromptBlocks)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.skipExistingPromptBlocks = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
function findNearestImageEmbed(editor, lineNumber) {
|
||||
const maxDistance = 8;
|
||||
for (let distance = 0; distance <= maxDistance; distance++) {
|
||||
for (const candidateLine of uniqueLineCandidates(lineNumber, distance, editor.lineCount())) {
|
||||
const line = editor.getLine(candidateLine);
|
||||
const matches = parseImageEmbedsFromLine(line, candidateLine);
|
||||
if (matches.length) {
|
||||
return matches[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function uniqueLineCandidates(baseLine, distance, lineCount) {
|
||||
const candidates = distance === 0 ? [baseLine] : [baseLine - distance, baseLine + distance];
|
||||
return [...new Set(candidates)].filter((line) => line >= 0 && line < lineCount);
|
||||
}
|
||||
|
||||
function findAllImageEmbeds(markdown) {
|
||||
const lines = markdown.split("\n");
|
||||
return lines.flatMap((line, index) => parseImageEmbedsFromLine(line, index));
|
||||
}
|
||||
|
||||
function parseImageEmbedsFromLine(line, lineNumber) {
|
||||
const matches = [];
|
||||
IMAGE_EMBED_RE.lastIndex = 0;
|
||||
let match;
|
||||
while ((match = IMAGE_EMBED_RE.exec(line)) !== null) {
|
||||
matches.push({
|
||||
line: lineNumber,
|
||||
link: match[1] || match[2],
|
||||
from: match.index,
|
||||
to: match.index + match[0].length,
|
||||
});
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
function hasPromptBlockAfter(editor, imageLine) {
|
||||
const lookAhead = Math.min(editor.lineCount(), imageLine + 8);
|
||||
for (let line = imageLine + 1; line < lookAhead; line++) {
|
||||
const text = editor.getLine(line);
|
||||
if (text.includes(PROMPT_BLOCK_MARKER)) {
|
||||
return true;
|
||||
}
|
||||
if (parseImageEmbedsFromLine(text, line).length) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function normalizeImageLink(link) {
|
||||
return decodeURIComponent(link)
|
||||
.split("#")[0]
|
||||
.split("?")[0]
|
||||
.trim();
|
||||
}
|
||||
|
||||
function extractImageMetadata(bytes, extension) {
|
||||
if (extension === "png") {
|
||||
return extractPngMetadata(bytes);
|
||||
}
|
||||
return extractStringMetadata(bytes);
|
||||
}
|
||||
|
||||
function extractPngMetadata(bytes) {
|
||||
const metadata = {};
|
||||
if (!hasPngSignature(bytes)) {
|
||||
return extractStringMetadata(bytes);
|
||||
}
|
||||
|
||||
let offset = 8;
|
||||
while (offset + 12 <= bytes.length) {
|
||||
const length = readUInt32(bytes, offset);
|
||||
const type = ascii(bytes.subarray(offset + 4, offset + 8));
|
||||
const dataStart = offset + 8;
|
||||
const dataEnd = dataStart + length;
|
||||
if (dataEnd + 4 > bytes.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (type === "tEXt") {
|
||||
readTextChunk(bytes.subarray(dataStart, dataEnd), metadata);
|
||||
} else if (type === "iTXt") {
|
||||
readInternationalTextChunk(bytes.subarray(dataStart, dataEnd), metadata);
|
||||
} else if (type === "zTXt") {
|
||||
readCompressedTextChunk(bytes.subarray(dataStart, dataEnd), metadata);
|
||||
}
|
||||
|
||||
offset = dataEnd + 4;
|
||||
if (type === "IEND") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(metadata).length ? metadata : extractStringMetadata(bytes);
|
||||
}
|
||||
|
||||
function readTextChunk(data, metadata) {
|
||||
const separator = data.indexOf(0);
|
||||
if (separator < 0) {
|
||||
return;
|
||||
}
|
||||
const key = latin1(data.subarray(0, separator));
|
||||
const value = decodeText(data.subarray(separator + 1));
|
||||
if (key && value) {
|
||||
metadata[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function readInternationalTextChunk(data, metadata) {
|
||||
let cursor = 0;
|
||||
const keyEnd = data.indexOf(0, cursor);
|
||||
if (keyEnd < 0) return;
|
||||
const key = latin1(data.subarray(cursor, keyEnd));
|
||||
cursor = keyEnd + 1;
|
||||
const compressionFlag = data[cursor++];
|
||||
cursor++;
|
||||
|
||||
const languageEnd = data.indexOf(0, cursor);
|
||||
if (languageEnd < 0) return;
|
||||
cursor = languageEnd + 1;
|
||||
|
||||
const translatedEnd = data.indexOf(0, cursor);
|
||||
if (translatedEnd < 0) return;
|
||||
cursor = translatedEnd + 1;
|
||||
|
||||
let textBytes = data.subarray(cursor);
|
||||
if (compressionFlag === 1) {
|
||||
textBytes = inflateBytes(textBytes);
|
||||
}
|
||||
|
||||
const value = decodeText(textBytes);
|
||||
if (key && value) {
|
||||
metadata[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function readCompressedTextChunk(data, metadata) {
|
||||
const separator = data.indexOf(0);
|
||||
if (separator < 0 || separator + 2 >= data.length) {
|
||||
return;
|
||||
}
|
||||
const key = latin1(data.subarray(0, separator));
|
||||
const compressed = data.subarray(separator + 2);
|
||||
const value = decodeText(inflateBytes(compressed));
|
||||
if (key && value) {
|
||||
metadata[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function extractStringMetadata(bytes) {
|
||||
const text = decodeText(bytes)
|
||||
.replace(/\u0000/g, "\n")
|
||||
.replace(/[^\S\r\n]+/g, " ");
|
||||
|
||||
const metadata = {};
|
||||
const patterns = [
|
||||
["parameters", /parameters[\s:=]+([\s\S]{20,8000}?)(?:\n[A-Z][A-Za-z ]{1,30}:|\n\n\n|$)/i],
|
||||
["UserComment", /UserComment[\s:=]+([\s\S]{20,8000}?)(?:\n[A-Z][A-Za-z ]{1,30}:|\n\n\n|$)/i],
|
||||
["ImageDescription", /ImageDescription[\s:=]+([\s\S]{20,8000}?)(?:\n[A-Z][A-Za-z ]{1,30}:|\n\n\n|$)/i],
|
||||
["prompt", /prompt["'\s:=]+([\s\S]{20,8000}?)(?:negative_prompt|Negative prompt|Steps:|Sampler:|Seed:|$)/i],
|
||||
];
|
||||
|
||||
for (const [key, pattern] of patterns) {
|
||||
const match = text.match(pattern);
|
||||
if (match && match[1]) {
|
||||
metadata[key] = cleanExtractedText(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
function pickPrompt(metadata) {
|
||||
const preferredKeys = [
|
||||
"parameters",
|
||||
"prompt",
|
||||
"Prompt",
|
||||
"UserComment",
|
||||
"ImageDescription",
|
||||
"Description",
|
||||
"Comment",
|
||||
"workflow",
|
||||
];
|
||||
|
||||
for (const key of preferredKeys) {
|
||||
if (metadata[key]) {
|
||||
return { key, value: normalizePromptValue(metadata[key]) };
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackKey = Object.keys(metadata).find((key) => looksLikePrompt(metadata[key]));
|
||||
return fallbackKey ? { key: fallbackKey, value: normalizePromptValue(metadata[fallbackKey]) } : null;
|
||||
}
|
||||
|
||||
function normalizePromptValue(value) {
|
||||
const trimmed = cleanExtractedText(value);
|
||||
const jsonPrompt = extractPromptFromJson(trimmed);
|
||||
return jsonPrompt || trimmed;
|
||||
}
|
||||
|
||||
function extractPromptFromJson(value) {
|
||||
if (!value.trim().startsWith("{") && !value.trim().startsWith("[")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
const collected = [];
|
||||
collectPromptStrings(parsed, collected);
|
||||
return collected.length ? collected.join("\n\n") : null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function collectPromptStrings(node, collected) {
|
||||
if (!node || collected.length >= 12) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof node === "string") {
|
||||
if (looksLikePrompt(node)) {
|
||||
collected.push(node.trim());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach((item) => collectPromptStrings(item, collected));
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof node === "object") {
|
||||
for (const [key, value] of Object.entries(node)) {
|
||||
const lower = key.toLowerCase();
|
||||
if (typeof value === "string" && ["text", "prompt", "positive", "inputs"].some((part) => lower.includes(part))) {
|
||||
collectPromptStrings(value, collected);
|
||||
} else if (typeof value === "object") {
|
||||
collectPromptStrings(value, collected);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function looksLikePrompt(value) {
|
||||
if (!value || value.length < 20) {
|
||||
return false;
|
||||
}
|
||||
return /(negative prompt|steps:|sampler:|cfg scale|seed:|masterpiece|cinematic|portrait|photo|prompt)/i.test(value);
|
||||
}
|
||||
|
||||
function cleanExtractedText(value) {
|
||||
return value
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/\r/g, "\n")
|
||||
.replace(/[^\x09\x0A\x0D\x20-\x7E\u0400-\u04FF\u2010-\u2027]+/g, " ")
|
||||
.replace(/[ \t]+\n/g, "\n")
|
||||
.replace(/\n{4,}/g, "\n\n\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function inflateBytes(bytes) {
|
||||
try {
|
||||
const zlib = require("zlib");
|
||||
return new Uint8Array(zlib.inflateSync(Buffer.from(bytes)));
|
||||
} catch (_) {
|
||||
return new Uint8Array();
|
||||
}
|
||||
}
|
||||
|
||||
function hasPngSignature(bytes) {
|
||||
return bytes.length > 8
|
||||
&& bytes[0] === 0x89
|
||||
&& bytes[1] === 0x50
|
||||
&& bytes[2] === 0x4e
|
||||
&& bytes[3] === 0x47
|
||||
&& bytes[4] === 0x0d
|
||||
&& bytes[5] === 0x0a
|
||||
&& bytes[6] === 0x1a
|
||||
&& bytes[7] === 0x0a;
|
||||
}
|
||||
|
||||
function readUInt32(bytes, offset) {
|
||||
return ((bytes[offset] << 24) | (bytes[offset + 1] << 16) | (bytes[offset + 2] << 8) | bytes[offset + 3]) >>> 0;
|
||||
}
|
||||
|
||||
function ascii(bytes) {
|
||||
return Array.from(bytes, (byte) => String.fromCharCode(byte)).join("");
|
||||
}
|
||||
|
||||
function latin1(bytes) {
|
||||
return new TextDecoder("latin1").decode(bytes).trim();
|
||||
}
|
||||
|
||||
function decodeText(bytes) {
|
||||
const utf8 = new TextDecoder("utf-8", { fatal: false }).decode(bytes);
|
||||
if (utf8.includes("\u0000")) {
|
||||
const utf16 = tryDecodeUtf16(bytes);
|
||||
if (utf16 && countPrintable(utf16) > countPrintable(utf8)) {
|
||||
return utf16;
|
||||
}
|
||||
}
|
||||
return utf8;
|
||||
}
|
||||
|
||||
function tryDecodeUtf16(bytes) {
|
||||
try {
|
||||
return new TextDecoder("utf-16le", { fatal: false }).decode(bytes);
|
||||
} catch (_) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function countPrintable(text) {
|
||||
return (text.match(/[\wа-яА-Я.,:;!?()[\]{}'" -]/g) || []).length;
|
||||
}
|
||||
Reference in New Issue
Block a user