Character-Cards / script.js
CultriX's picture
First Commit
d8c9b50 verified
/**
* 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