mirror of
				https://github.com/SillyTavern/SillyTavern.git
				synced 2025-06-05 21:59:27 +02:00 
			
		
		
		
	Import Novel PNG lorebooks
This commit is contained in:
		| @@ -1973,7 +1973,7 @@ | |||||||
|                                 <div id="world_popup_new" class="menu_button fa-solid fa-plus" title="New Entry"></div> |                                 <div id="world_popup_new" class="menu_button fa-solid fa-plus" title="New Entry"></div> | ||||||
|                                 <div class="flex-container"> |                                 <div class="flex-container"> | ||||||
|                                     <form id="form_world_import" action="javascript:void(null);" method="post" enctype="multipart/form-data"> |                                     <form id="form_world_import" action="javascript:void(null);" method="post" enctype="multipart/form-data"> | ||||||
|                                         <input type="file" id="world_import_file" accept=".json,.lorebook" name="avatar" hidden> |                                         <input type="file" id="world_import_file" accept=".json,.lorebook,.png" name="avatar" hidden> | ||||||
|                                     </form> |                                     </form> | ||||||
|                                     <form id="form_rename_world" action="javascript:void(null);" method="post" enctype="multipart/form-data"> |                                     <form id="form_rename_world" action="javascript:void(null);" method="post" enctype="multipart/form-data"> | ||||||
|                                         <div id="world_create_button" class="menu_button fa-solid fa-globe fa-fw" title="Create"></div> |                                         <div id="world_create_button" class="menu_button fa-solid fa-globe fa-fw" title="Create"></div> | ||||||
|   | |||||||
| @@ -50,6 +50,19 @@ export function getFileText(file) { | |||||||
|     }); |     }); | ||||||
| } | } | ||||||
|  |  | ||||||
|  | export function getFileBuffer(file) { | ||||||
|  |     return new Promise((resolve, reject) => { | ||||||
|  |         const reader = new FileReader(); | ||||||
|  |         reader.readAsArrayBuffer(file); | ||||||
|  |         reader.onload = function () { | ||||||
|  |             resolve(reader.result); | ||||||
|  |         }; | ||||||
|  |         reader.onerror = function (error) { | ||||||
|  |             reject(error); | ||||||
|  |         }; | ||||||
|  |     }); | ||||||
|  | } | ||||||
|  |  | ||||||
| export function getBase64Async(file) { | export function getBase64Async(file) { | ||||||
|     return new Promise((resolve, reject) => { |     return new Promise((resolve, reject) => { | ||||||
|         const reader = new FileReader(); |         const reader = new FileReader(); | ||||||
| @@ -492,3 +505,114 @@ export class RateLimiter { | |||||||
|         console.debug(`RateLimiter.waitForResolve() ${this._lastResolveTime}`); |         console.debug(`RateLimiter.waitForResolve() ${this._lastResolveTime}`); | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | // Taken from https://github.com/LostRuins/lite.koboldai.net/blob/main/index.html | ||||||
|  | //import tavern png data. adapted from png-chunks-extract under MIT license | ||||||
|  | //accepts png input data, and returns the extracted JSON | ||||||
|  | export function extractDataFromPng(data, identifier = 'chara') { | ||||||
|  |     console.log("Attempting PNG import..."); | ||||||
|  |     let uint8 = new Uint8Array(4); | ||||||
|  |     let uint32 = new Uint32Array(uint8.buffer); | ||||||
|  |  | ||||||
|  |     //check if png header is valid | ||||||
|  |     if (!data || data[0] !== 0x89 || data[1] !== 0x50 || data[2] !== 0x4E || data[3] !== 0x47 || data[4] !== 0x0D || data[5] !== 0x0A || data[6] !== 0x1A || data[7] !== 0x0A) { | ||||||
|  |         console.log("PNG header invalid") | ||||||
|  |         return null; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     let ended = false; | ||||||
|  |     let chunks = []; | ||||||
|  |     let idx = 8; | ||||||
|  |  | ||||||
|  |     while (idx < data.length) { | ||||||
|  |         // Read the length of the current chunk, | ||||||
|  |         // which is stored as a Uint32. | ||||||
|  |         uint8[3] = data[idx++]; | ||||||
|  |         uint8[2] = data[idx++]; | ||||||
|  |         uint8[1] = data[idx++]; | ||||||
|  |         uint8[0] = data[idx++]; | ||||||
|  |  | ||||||
|  |         // Chunk includes name/type for CRC check (see below). | ||||||
|  |         let length = uint32[0] + 4; | ||||||
|  |         let chunk = new Uint8Array(length); | ||||||
|  |         chunk[0] = data[idx++]; | ||||||
|  |         chunk[1] = data[idx++]; | ||||||
|  |         chunk[2] = data[idx++]; | ||||||
|  |         chunk[3] = data[idx++]; | ||||||
|  |  | ||||||
|  |         // Get the name in ASCII for identification. | ||||||
|  |         let name = ( | ||||||
|  |             String.fromCharCode(chunk[0]) + | ||||||
|  |             String.fromCharCode(chunk[1]) + | ||||||
|  |             String.fromCharCode(chunk[2]) + | ||||||
|  |             String.fromCharCode(chunk[3]) | ||||||
|  |         ); | ||||||
|  |  | ||||||
|  |         // The IHDR header MUST come first. | ||||||
|  |         if (!chunks.length && name !== 'IHDR') { | ||||||
|  |             console.log('Warning: IHDR header missing'); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // The IEND header marks the end of the file, | ||||||
|  |         // so on discovering it break out of the loop. | ||||||
|  |         if (name === 'IEND') { | ||||||
|  |             ended = true; | ||||||
|  |             chunks.push({ | ||||||
|  |                 name: name, | ||||||
|  |                 data: new Uint8Array(0) | ||||||
|  |             }); | ||||||
|  |             break; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // Read the contents of the chunk out of the main buffer. | ||||||
|  |         for (let i = 4; i < length; i++) { | ||||||
|  |             chunk[i] = data[idx++]; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // Read out the CRC value for comparison. | ||||||
|  |         // It's stored as an Int32. | ||||||
|  |         uint8[3] = data[idx++]; | ||||||
|  |         uint8[2] = data[idx++]; | ||||||
|  |         uint8[1] = data[idx++]; | ||||||
|  |         uint8[0] = data[idx++]; | ||||||
|  |  | ||||||
|  |  | ||||||
|  |         // The chunk data is now copied to remove the 4 preceding | ||||||
|  |         // bytes used for the chunk name/type. | ||||||
|  |         let chunkData = new Uint8Array(chunk.buffer.slice(4)); | ||||||
|  |  | ||||||
|  |         chunks.push({ | ||||||
|  |             name: name, | ||||||
|  |             data: chunkData | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (!ended) { | ||||||
|  |         console.log('.png file ended prematurely: no IEND header was found'); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     //find the chunk with the chara name, just check first and last letter | ||||||
|  |     let found = chunks.filter(x => ( | ||||||
|  |         x.name == "tEXt" | ||||||
|  |         && x.data.length > identifier.length | ||||||
|  |         && x.data.slice(0, identifier.length).every((v, i) => String.fromCharCode(v) == identifier[i]))); | ||||||
|  |  | ||||||
|  |     if (found.length == 0) { | ||||||
|  |         console.log('PNG Image contains no data'); | ||||||
|  |         return null; | ||||||
|  |     } else { | ||||||
|  |         try { | ||||||
|  |             let b64buf = ""; | ||||||
|  |             let bytes = found[0].data; //skip the chara | ||||||
|  |             for (let i = identifier.length + 1; i < bytes.length; i++) { | ||||||
|  |                 b64buf += String.fromCharCode(bytes[i]); | ||||||
|  |             } | ||||||
|  |             let decoded = JSON.parse(atob(b64buf)); | ||||||
|  |             console.log(decoded); | ||||||
|  |             return decoded; | ||||||
|  |         } catch (e) { | ||||||
|  |             console.log("Error decoding b64 in image: " + e); | ||||||
|  |             return null; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|   | |||||||
| @@ -1,5 +1,5 @@ | |||||||
| import { saveSettings, callPopup, substituteParams, getTokenCount, getRequestHeaders, chat_metadata, this_chid, characters } from "../script.js"; | import { saveSettings, callPopup, substituteParams, getTokenCount, getRequestHeaders, chat_metadata, this_chid, characters } from "../script.js"; | ||||||
| import { download, debounce, initScrollHeight, resetScrollHeight, parseJsonFile } from "./utils.js"; | import { download, debounce, initScrollHeight, resetScrollHeight, parseJsonFile, extractDataFromPng, getFileBuffer } from "./utils.js"; | ||||||
| import { getContext } from "./extensions.js"; | import { getContext } from "./extensions.js"; | ||||||
| import { metadata_keys, shouldWIAddPrompt } from "./extensions/floating-prompt/index.js"; | import { metadata_keys, shouldWIAddPrompt } from "./extensions/floating-prompt/index.js"; | ||||||
| import { registerSlashCommand } from "./slash-commands.js"; | import { registerSlashCommand } from "./slash-commands.js"; | ||||||
| @@ -952,14 +952,25 @@ jQuery(() => { | |||||||
|         const formData = new FormData($("#form_world_import").get(0)); |         const formData = new FormData($("#form_world_import").get(0)); | ||||||
|  |  | ||||||
|         try { |         try { | ||||||
|             // File should be a JSON file |             let jsonData; | ||||||
|             const jsonData = await parseJsonFile(file); |  | ||||||
|  |             if (file.name.endsWith('.png')) { | ||||||
|  |                 const buffer = new Uint8Array(await getFileBuffer(file)); | ||||||
|  |                 jsonData = extractDataFromPng(buffer, 'naidata'); | ||||||
|  |             } else { | ||||||
|  |                 // File should be a JSON file | ||||||
|  |                 jsonData = await parseJsonFile(file); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (jsonData === undefined || jsonData === null) { | ||||||
|  |                 toastr.error(`File is not valid: ${file.name}`); | ||||||
|  |                 return; | ||||||
|  |             } | ||||||
|  |  | ||||||
|             // Convert Novel Lorebook |             // Convert Novel Lorebook | ||||||
|             if (jsonData.lorebookVersion !== undefined) { |             if (jsonData.lorebookVersion !== undefined) { | ||||||
|                 formData.append('convertedData', JSON.stringify(convertNovelLorebook(jsonData))); |                 formData.append('convertedData', JSON.stringify(convertNovelLorebook(jsonData))); | ||||||
|             } |             } | ||||||
|  |  | ||||||
|         } catch (error) { |         } catch (error) { | ||||||
|             toastr.error(`Error parsing file: ${error}`); |             toastr.error(`Error parsing file: ${error}`); | ||||||
|             return; |             return; | ||||||
|   | |||||||
| @@ -329,7 +329,7 @@ app.use('/characters', (req, res) => { | |||||||
|         res.send(data); |         res.send(data); | ||||||
|     }); |     }); | ||||||
| }); | }); | ||||||
| app.use(multer({ dest: "uploads" }).single("avatar")); | app.use(multer({ dest: "uploads", limits: { fieldSize: 10 * 1024 * 1024 } }).single("avatar")); | ||||||
| app.get("/", function (request, response) { | app.get("/", function (request, response) { | ||||||
|     response.sendFile(process.cwd() + "/public/index.html"); |     response.sendFile(process.cwd() + "/public/index.html"); | ||||||
| }); | }); | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user