Spaces:
Running
Running
| /** | |
| * 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<base64>") | |
| 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 |