commit 492600719734c6e9ba31e17598383252c3ceaf37 Author: Codex Date: Sat Jun 20 19:32:05 2026 +0800 Add Obsidian EXIF prompt plugin diff --git a/13d58f12-2267-40a2-b92c-bb095285c1a8.png b/13d58f12-2267-40a2-b92c-bb095285c1a8.png new file mode 100644 index 0000000..963db57 Binary files /dev/null and b/13d58f12-2267-40a2-b92c-bb095285c1a8.png differ diff --git a/QUICK_INSTALL.md b/QUICK_INSTALL.md new file mode 100644 index 0000000..9ad8791 --- /dev/null +++ b/QUICK_INSTALL.md @@ -0,0 +1,19 @@ +# Быстрая установка EXIF Prompt Extractor + +Папка плагина уже должна называться `obsidian-exif-prompt`. + +```powershell +Copy-Item -Path .\obsidian-exif-prompt -Destination C:\Users\dimir\Documents\ObsidianTask\.obsidian\plugins\obsidian-exif-prompt -Recurse -Force +``` + +Дальше в Obsidian: + +1. Перезапустите Obsidian или выполните `Reload app`. +2. Откройте `Settings -> Community plugins`. +3. Включите `EXIF Prompt Extractor`. +4. В заметке поставьте курсор рядом с изображением и нажмите `Ctrl/Cmd + Alt + P`. + +Команды плагина: + +- `Extract prompt for image at cursor` +- `Extract prompts for all images in note` diff --git a/README.md b/README.md new file mode 100644 index 0000000..9373ead --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ +# Obsidian EXIF Prompt Extractor + +Плагин для Obsidian, который извлекает prompt из metadata изображения, добавленного в заметку, и вставляет его ниже изображения в блоке кода. + +## Быстрая установка + +1. Скопируйте папку `obsidian-exif-prompt` в папку плагинов вашего vault: + + ```powershell + C:\Users\dimir\Documents\ObsidianTask\.obsidian\plugins\obsidian-exif-prompt + ``` + +2. Перезапустите Obsidian или выполните команду `Reload app`. +3. Откройте `Settings -> Community plugins`. +4. Если community plugins выключены, включите их. +5. В списке installed plugins включите `EXIF Prompt Extractor`. + +## Как пользоваться + +- Поставьте курсор на строку с изображением или рядом с ним. +- Запустите команду `Extract prompt for image at cursor`. +- Либо нажмите иконку плагина на левой панели. +- Для обработки всей заметки запустите `Extract prompts for all images in note`. + +Хоткей по умолчанию: `Ctrl/Cmd + Alt + P`. + +Поддерживаются ссылки: + +```md +![[image.png]] +![alt](image.png) +``` + +Плагин поддерживает PNG, JPG, JPEG и WebP. Лучше всего извлекаются prompts из PNG, созданных Stable Diffusion/ComfyUI/Automatic1111, где prompt обычно хранится в текстовых metadata chunks. + +## Ручная установка из репозитория + +Если вы клонируете весь репозиторий, скопируйте в Obsidian только папку `obsidian-exif-prompt`. Obsidian ожидает, что внутри папки плагина лежат файлы: + +```text +manifest.json +main.js +styles.css +``` + +## Обновление установленного плагина + +Скопируйте свежие файлы из `obsidian-exif-prompt` поверх установленной папки: + +```powershell +Copy-Item -Path .\obsidian-exif-prompt\* -Destination C:\Users\dimir\Documents\ObsidianTask\.obsidian\plugins\obsidian-exif-prompt -Recurse -Force +``` + +После обновления перезапустите Obsidian или выполните `Reload app`. diff --git a/a8cd9f07-3f77-4bc1-b82c-6f4c1e2e76d5.png b/a8cd9f07-3f77-4bc1-b82c-6f4c1e2e76d5.png new file mode 100644 index 0000000..403b79a Binary files /dev/null and b/a8cd9f07-3f77-4bc1-b82c-6f4c1e2e76d5.png differ diff --git a/obsidian-exif-prompt/README.md b/obsidian-exif-prompt/README.md new file mode 100644 index 0000000..e07bb38 --- /dev/null +++ b/obsidian-exif-prompt/README.md @@ -0,0 +1,20 @@ +# EXIF Prompt Extractor + +Obsidian plugin that extracts generation prompts from embedded image metadata and inserts the prompt below the image in a fenced code block. + +## Usage + +- Put the cursor on or near an embedded image and run `Extract prompt for image at cursor`. +- Click the ribbon icon with the image-plus symbol. +- Run `Extract prompts for all images in note` to process the whole active note. + +The default hotkey is `Ctrl/Cmd + Alt + P`; it can be changed in Obsidian's hotkey settings. + +Supported image links: + +```md +![[image.png]] +![alt](image.png) +``` + +The plugin is desktop-only because it reads binary image metadata from the local vault. diff --git a/obsidian-exif-prompt/main.js b/obsidian-exif-prompt/main.js new file mode 100644 index 0000000..1a6b421 --- /dev/null +++ b/obsidian-exif-prompt/main.js @@ -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 = ""; + +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; +} diff --git a/obsidian-exif-prompt/manifest.json b/obsidian-exif-prompt/manifest.json new file mode 100644 index 0000000..475f985 --- /dev/null +++ b/obsidian-exif-prompt/manifest.json @@ -0,0 +1,10 @@ +{ + "id": "obsidian-exif-prompt", + "name": "EXIF Prompt Extractor", + "version": "1.0.0", + "minAppVersion": "1.5.0", + "description": "Extracts generation prompts from image metadata and inserts them below images in code blocks.", + "author": "Codex", + "authorUrl": "", + "isDesktopOnly": true +} diff --git a/obsidian-exif-prompt/styles.css b/obsidian-exif-prompt/styles.css new file mode 100644 index 0000000..8a4596f --- /dev/null +++ b/obsidian-exif-prompt/styles.css @@ -0,0 +1,3 @@ +.exif-prompt-extractor-placeholder { + display: none; +}