/** * CardForge - SillyTavern Character Studio * Advanced PNG metadata handling and character management */ class CardForge { constructor() { this.currentCharacter = this.getDefaultCharacter(); this.extractedCharacter = null; this.currentAvatar = null; this.crc32Table = this.generateCRC32Table(); } getDefaultCharacter() { return { name: '', description: '', personality: '', scenario: '', first_mes: '', mes_example: '', creatorcomment: '', creator: '', character_version: '', tags: [], data: {} }; } generateCRC32Table() { const table = new Int32Array(256); for (let i = 0; i < 256; i++) { let c = i; for (let k = 0; k < 8; k++) { c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1); } table[i] = c; } return table; } calculateCRC32(bytes) { let c = -1; for (let i = 0; i < bytes.length; i++) { c = this.crc32Table[(c ^ bytes[i]) & 0xff] ^ (c >>> 8); } return c ^ -1; } stringToUint8Array(str) { return new TextEncoder().encode(str); } uint8ArrayToString(arr) { return new TextDecoder().decode(arr); } /** * Reads all chunks from a PNG file */ readPNGChunks(arrayBuffer) { const view = new DataView(arrayBuffer); const chunks = []; let offset = 8; // Skip PNG signature (8 bytes) // PNG signature verification: 89 50 4E 47 0D 0A 1A 0A const signature = new Uint8Array(arrayBuffer, 0, 8); const expectedSignature = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; if (!expectedSignature.every((byte, i) => byte === signature[i])) { throw new Error('Invalid PNG signature'); } while (offset < arrayBuffer.byteLength) { const length = view.getUint32(offset, false); // Big-endian const type = this.uint8ArrayToString(new Uint8Array(arrayBuffer, offset + 4, 4)); const data = new Uint8Array(arrayBuffer, offset + 8, length); const crc = view.getUint32(offset + 8 + length, false); chunks.push({ type, data, crc, offset, length }); offset += 12 + length; if (type === 'IEND') break; } return chunks; } /** * Creates a PNG chunk (tEXt, iTXt, etc.) */ createChunk(type, data) { const typeBytes = this.stringToUint8Array(type); const length = data.length; // Create chunk structure: [length:4][type:4][data:N][crc:4] const chunk = new Uint8Array(4 + 4 + length + 4); const view = new DataView(chunk.buffer); // Length view.setUint32(0, length, false); // Type chunk.set(typeBytes, 4); // Data chunk.set(data, 8); // CRC32 of type + data const crcData = new Uint8Array(chunk.buffer, 4, 4 + length); const crc = this.calculateCRC32(crcData); view.setUint32(8 + length, crc >>> 0, false); return chunk; } /** * Embeds SillyTavern metadata into PNG */ embedMetadata(pngBuffer, characterData) { try { const chunks = this.readPNGChunks(pngBuffer); // Prepare metadata const metadata = { ...characterData, spec: 'chara_card_v2', spec_version: '2.0', data: { ...characterData.data, name: characterData.name, description: characterData.description, personality: characterData.personality, scenario: characterData.scenario, first_mes: characterData.first_mes, mes_example: characterData.mes_example, creatorcomment: characterData.creatorcomment, tags: characterData.tags, creator: characterData.creator, character_version: characterData.character_version } }; // Convert to base64 const jsonStr = JSON.stringify(metadata); const base64Data = btoa(unescape(encodeURIComponent(jsonStr))); // Create tEXt chunk with keyword 'chara' const textContent = `chara\u0000${base64Data}`; const textData = this.stringToUint8Array(textContent); // Find insertion point (before first IDAT or at end if no IDAT) let insertIndex = chunks.findIndex(c => c.type === 'IDAT'); if (insertIndex === -1) insertIndex = chunks.length; // Create new chunk array const newChunk = { type: 'tEXt', data: textData, crc: 0 }; chunks.splice(insertIndex, 0, newChunk); // Rebuild PNG return this.rebuildPNG(chunks); } catch (error) { console.error('Error embedding metadata:', error); throw new Error('Failed to embed metadata: ' + error.message); } } /** * Rebuilds PNG file from chunks */ rebuildPNG(chunks) { // Calculate total size let totalSize = 8; // PNG signature // Calculate chunk sizes for (const chunk of chunks) { totalSize += 12 + chunk.data.length; // 4 (length) + 4 (type) + N (data) + 4 (crc) } const result = new Uint8Array(totalSize); const view = new DataView(result.buffer); let offset = 0; // Write PNG signature const signature = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; result.set(signature, offset); offset += 8; // Write chunks for (const chunk of chunks) { // Length view.setUint32(offset, chunk.data.length, false); offset += 4; // Type const typeBytes = this.stringToUint8Array(chunk.type); result.set(typeBytes, offset); offset += 4; // Data result.set(chunk.data, offset); offset += chunk.data.length; // CRC if (chunk.crc && chunk.crc !== 0) { view.setUint32(offset, chunk.crc >>> 0, false); } else { // Calculate CRC for new chunks const crcData = new Uint8Array(result.buffer, offset - 4 - chunk.data.length, 4 + chunk.data.length); const crc = this.calculateCRC32(crcData); view.setUint32(offset, crc >>> 0, false); } offset += 4; } return result.buffer; } /** * Extracts metadata from PNG */ extractMetadata(pngBuffer) { try { const chunks = this.readPNGChunks(pngBuffer); // Look for tEXt chunks with 'chara' keyword for (const chunk of chunks) { if (chunk.type === 'tEXt') { const text = this.uint8ArrayToString(chunk.data); // Check for chara keyword (format: "chara\0") if (text.startsWith('chara\u0000')) { const base64Data = text.substring(6); const jsonStr = decodeURIComponent(escape(atob(base64Data))); const data = JSON.parse(jsonStr); return { success: true, data: this.normalizeCharacterData(data), format: 'PNG tEXt (SillyTavern)', raw: data }; } } // Also check iTXt chunks (international text, UTF-8) if (chunk.type === 'iTXt') { const text = this.uint8ArrayToString(chunk.data); if (text.includes('chara')) { // Parse iTXt format: keyword\0compression\0language\0translated\0text const parts = text.split('\u0000'); if (parts[0] === 'chara' && parts.length >= 5) { const base64Data = parts[4]; const jsonStr = decodeURIComponent(escape(atob(base64Data))); const data = JSON.parse(jsonStr); return { success: true, data: this.normalizeCharacterData(data), format: 'PNG iTXt (SillyTavern)', raw: data }; } } } } return { success: false, error: 'No character metadata found in PNG chunks', errorCode: 'NO_METADATA' }; } catch (error) { return { success: false, error: error.message, errorCode: 'PARSE_ERROR' }; } } /** * Normalizes character data from various formats (v1, v2, v3) */ normalizeCharacterData(data) { // Handle V2 format with nested data object if (data.data && typeof data.data === 'object') { return { name: data.data.name || data.name || '', description: data.data.description || data.description || '', personality: data.data.personality || data.personality || '', scenario: data.data.scenario || data.scenario || '', first_mes: data.data.first_mes || data.first_mes || '', mes_example: data.data.mes_example || data.mes_example || '', creatorcomment: data.data.creatorcomment || data.creatorcomment || '', creator: data.data.creator || data.creator || '', character_version: data.data.character_version || data.character_version || '1.0', tags: data.data.tags || data.tags || [], spec: data.spec || 'unknown', spec_version: data.spec_version || '1.0', data: data.data }; } // Handle V1 flat format return { name: data.name || '', description: data.description || '', personality: data.personality || '', scenario: data.scenario || '', first_mes: data.first_mes || '', mes_example: data.mes_example || '', creatorcomment: data.creatorcomment || '', creator: data.creator || '', character_version: data.character_version || '1.0', tags: data.tags || [], spec: data.spec || 'chara_card_v1', spec_version: data.spec_version || '1.0', data: data.data || {} }; } /** * Validates character data */ validateCharacter(data) { const errors = []; if (!data.name || data.name.trim() === '') { errors.push('Character name is required'); } if (data.name && data.name.length > 100) { errors.push('Character name is too long (max 100 characters)'); } return { valid: errors.length === 0, errors }; } /** * Generates a placeholder avatar with initials */ generatePlaceholderAvatar(name, width = 400, height = 600) { const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; const ctx = canvas.getContext('2d'); // Background gradient const gradient = ctx.createLinearGradient(0, 0, width, height); gradient