export/import
Browse files
public/assets/snap_logo.png
ADDED
![]() |
Git LFS Details
|
src/lib/components/MonsterGenerator/MonsterGenerator.svelte
CHANGED
@@ -1,10 +1,13 @@
|
|
1 |
<script lang="ts">
|
2 |
import type { MonsterGeneratorProps, MonsterWorkflowState, CaptionType, CaptionLength, MonsterStats } from '$lib/types';
|
|
|
3 |
import UploadStep from './UploadStep.svelte';
|
4 |
import WorkflowProgress from './WorkflowProgress.svelte';
|
5 |
import MonsterResult from './MonsterResult.svelte';
|
6 |
import { makeWhiteTransparent } from '$lib/utils/imageProcessing';
|
7 |
import { saveMonster } from '$lib/db/monsters';
|
|
|
|
|
8 |
|
9 |
interface Props extends MonsterGeneratorProps {}
|
10 |
|
@@ -79,6 +82,32 @@ Remember to base the stats on how unique/powerful the original object was. Commo
|
|
79 |
|
80 |
Write your response within \`\`\`json\`\`\``;
|
81 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
82 |
async function handleImageSelected(file: File) {
|
83 |
if (!joyCaptionClient || !rwkvClient || !fluxClient) {
|
84 |
state.error = "Services not connected. Please wait...";
|
@@ -87,7 +116,16 @@ Write your response within \`\`\`json\`\`\``;
|
|
87 |
|
88 |
state.userImage = file;
|
89 |
state.error = null;
|
90 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
91 |
}
|
92 |
|
93 |
async function startWorkflow() {
|
|
|
1 |
<script lang="ts">
|
2 |
import type { MonsterGeneratorProps, MonsterWorkflowState, CaptionType, CaptionLength, MonsterStats } from '$lib/types';
|
3 |
+
import type { PicletInstance } from '$lib/db/schema';
|
4 |
import UploadStep from './UploadStep.svelte';
|
5 |
import WorkflowProgress from './WorkflowProgress.svelte';
|
6 |
import MonsterResult from './MonsterResult.svelte';
|
7 |
import { makeWhiteTransparent } from '$lib/utils/imageProcessing';
|
8 |
import { saveMonster } from '$lib/db/monsters';
|
9 |
+
import { extractPicletMetadata } from '$lib/services/picletMetadata';
|
10 |
+
import { savePicletInstance } from '$lib/db/piclets';
|
11 |
|
12 |
interface Props extends MonsterGeneratorProps {}
|
13 |
|
|
|
82 |
|
83 |
Write your response within \`\`\`json\`\`\``;
|
84 |
|
85 |
+
async function importPiclet(picletData: PicletInstance) {
|
86 |
+
state.isProcessing = true;
|
87 |
+
state.currentStep = 'complete';
|
88 |
+
|
89 |
+
try {
|
90 |
+
// Save the imported piclet
|
91 |
+
const savedId = await savePicletInstance(picletData);
|
92 |
+
|
93 |
+
// Create a success state similar to generation
|
94 |
+
state.monsterImage = {
|
95 |
+
imageUrl: picletData.imageUrl,
|
96 |
+
imageData: picletData.imageData
|
97 |
+
};
|
98 |
+
|
99 |
+
// Show import success
|
100 |
+
state.isProcessing = false;
|
101 |
+
alert(`Successfully imported ${picletData.nickname || picletData.typeId}!`);
|
102 |
+
|
103 |
+
// Reset to allow another import/generation
|
104 |
+
setTimeout(() => reset(), 2000);
|
105 |
+
} catch (error) {
|
106 |
+
state.error = `Failed to import piclet: ${error}`;
|
107 |
+
state.isProcessing = false;
|
108 |
+
}
|
109 |
+
}
|
110 |
+
|
111 |
async function handleImageSelected(file: File) {
|
112 |
if (!joyCaptionClient || !rwkvClient || !fluxClient) {
|
113 |
state.error = "Services not connected. Please wait...";
|
|
|
116 |
|
117 |
state.userImage = file;
|
118 |
state.error = null;
|
119 |
+
|
120 |
+
// Check if this is a piclet card with metadata
|
121 |
+
const picletData = await extractPicletMetadata(file);
|
122 |
+
if (picletData) {
|
123 |
+
// Import existing piclet
|
124 |
+
await importPiclet(picletData);
|
125 |
+
} else {
|
126 |
+
// Generate new piclet
|
127 |
+
startWorkflow();
|
128 |
+
}
|
129 |
}
|
130 |
|
131 |
async function startWorkflow() {
|
src/lib/components/Piclets/PicletDetail.svelte
CHANGED
@@ -3,6 +3,7 @@
|
|
3 |
import type { PicletInstance } from '$lib/db/schema';
|
4 |
import { deletePicletInstance } from '$lib/db/piclets';
|
5 |
import { uiStore } from '$lib/stores/ui';
|
|
|
6 |
|
7 |
interface Props {
|
8 |
instance: PicletInstance;
|
@@ -14,6 +15,7 @@
|
|
14 |
let showDeleteConfirm = $state(false);
|
15 |
let selectedTab = $state<'about' | 'stats' | 'actions'>('about');
|
16 |
let expandedMoves = $state(new Set<number>());
|
|
|
17 |
|
18 |
onMount(() => {
|
19 |
uiStore.openDetailPage();
|
@@ -53,6 +55,18 @@
|
|
53 |
}
|
54 |
expandedMoves = new Set(expandedMoves);
|
55 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
56 |
</script>
|
57 |
|
58 |
<div class="detail-page">
|
@@ -228,6 +242,9 @@
|
|
228 |
<button class="btn btn-danger" onclick={handleDelete}>Yes, Release</button>
|
229 |
<button class="btn btn-secondary" onclick={() => showDeleteConfirm = false}>Cancel</button>
|
230 |
{:else}
|
|
|
|
|
|
|
231 |
<button class="btn btn-danger" onclick={() => showDeleteConfirm = true}>Release Piclet</button>
|
232 |
{/if}
|
233 |
</div>
|
@@ -587,6 +604,18 @@
|
|
587 |
transform: scale(0.95);
|
588 |
}
|
589 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
590 |
.btn-danger {
|
591 |
background: #ff3b30;
|
592 |
color: white;
|
|
|
3 |
import type { PicletInstance } from '$lib/db/schema';
|
4 |
import { deletePicletInstance } from '$lib/db/piclets';
|
5 |
import { uiStore } from '$lib/stores/ui';
|
6 |
+
import { downloadPicletCard } from '$lib/services/picletExport';
|
7 |
|
8 |
interface Props {
|
9 |
instance: PicletInstance;
|
|
|
15 |
let showDeleteConfirm = $state(false);
|
16 |
let selectedTab = $state<'about' | 'stats' | 'actions'>('about');
|
17 |
let expandedMoves = $state(new Set<number>());
|
18 |
+
let isSharing = $state(false);
|
19 |
|
20 |
onMount(() => {
|
21 |
uiStore.openDetailPage();
|
|
|
55 |
}
|
56 |
expandedMoves = new Set(expandedMoves);
|
57 |
}
|
58 |
+
|
59 |
+
async function handleShare() {
|
60 |
+
isSharing = true;
|
61 |
+
try {
|
62 |
+
await downloadPicletCard(instance);
|
63 |
+
} catch (err) {
|
64 |
+
console.error('Failed to share piclet:', err);
|
65 |
+
alert('Failed to create shareable image');
|
66 |
+
} finally {
|
67 |
+
isSharing = false;
|
68 |
+
}
|
69 |
+
}
|
70 |
</script>
|
71 |
|
72 |
<div class="detail-page">
|
|
|
242 |
<button class="btn btn-danger" onclick={handleDelete}>Yes, Release</button>
|
243 |
<button class="btn btn-secondary" onclick={() => showDeleteConfirm = false}>Cancel</button>
|
244 |
{:else}
|
245 |
+
<button class="btn btn-primary" onclick={handleShare} disabled={isSharing}>
|
246 |
+
{isSharing ? 'Creating...' : 'Share Piclet'}
|
247 |
+
</button>
|
248 |
<button class="btn btn-danger" onclick={() => showDeleteConfirm = true}>Release Piclet</button>
|
249 |
{/if}
|
250 |
</div>
|
|
|
604 |
transform: scale(0.95);
|
605 |
}
|
606 |
|
607 |
+
.btn-primary {
|
608 |
+
background: #007bff;
|
609 |
+
color: white;
|
610 |
+
width: 100%;
|
611 |
+
margin-bottom: 8px;
|
612 |
+
}
|
613 |
+
|
614 |
+
.btn-primary:disabled {
|
615 |
+
opacity: 0.7;
|
616 |
+
cursor: not-allowed;
|
617 |
+
}
|
618 |
+
|
619 |
.btn-danger {
|
620 |
background: #ff3b30;
|
621 |
color: white;
|
src/lib/services/picletExport.ts
ADDED
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import type { PicletInstance } from '$lib/db/schema';
|
2 |
+
import { embedPicletMetadata } from './picletMetadata';
|
3 |
+
|
4 |
+
/**
|
5 |
+
* Generates a shareable image of a piclet with embedded metadata
|
6 |
+
*/
|
7 |
+
export async function generateShareableImage(piclet: PicletInstance): Promise<Blob> {
|
8 |
+
// Create canvas
|
9 |
+
const canvas = document.createElement('canvas');
|
10 |
+
const ctx = canvas.getContext('2d');
|
11 |
+
if (!ctx) throw new Error('Could not create canvas context');
|
12 |
+
|
13 |
+
// Set canvas size (square format for social media sharing)
|
14 |
+
const canvasSize = 800;
|
15 |
+
canvas.width = canvasSize;
|
16 |
+
canvas.height = canvasSize;
|
17 |
+
|
18 |
+
// Fill background with a gradient
|
19 |
+
const gradient = ctx.createLinearGradient(0, 0, 0, canvasSize);
|
20 |
+
gradient.addColorStop(0, '#87CEEB'); // Sky blue
|
21 |
+
gradient.addColorStop(0.6, '#98D98E'); // Light green
|
22 |
+
gradient.addColorStop(1, '#567d46'); // Dark green
|
23 |
+
ctx.fillStyle = gradient;
|
24 |
+
ctx.fillRect(0, 0, canvasSize, canvasSize);
|
25 |
+
|
26 |
+
// Load and draw grass platform
|
27 |
+
const grassImg = await loadImage('/assets/grass.PNG');
|
28 |
+
const platformSize = 400;
|
29 |
+
const platformX = (canvasSize - platformSize) / 2;
|
30 |
+
const platformY = canvasSize - platformSize + 100;
|
31 |
+
ctx.drawImage(grassImg, platformX, platformY, platformSize, platformSize);
|
32 |
+
|
33 |
+
// Load and draw piclet
|
34 |
+
const picletImg = await loadImage(piclet.imageData || piclet.imageUrl);
|
35 |
+
const picletSize = 300;
|
36 |
+
const picletX = (canvasSize - picletSize) / 2;
|
37 |
+
const picletY = platformY - picletSize + 100;
|
38 |
+
ctx.drawImage(picletImg, picletX, picletY, picletSize, picletSize);
|
39 |
+
|
40 |
+
// Add piclet info text
|
41 |
+
ctx.fillStyle = 'white';
|
42 |
+
ctx.strokeStyle = 'black';
|
43 |
+
ctx.lineWidth = 4;
|
44 |
+
ctx.font = 'bold 48px Arial';
|
45 |
+
ctx.textAlign = 'center';
|
46 |
+
|
47 |
+
const nameText = piclet.nickname || piclet.typeId;
|
48 |
+
const levelText = `Lv.${piclet.level}`;
|
49 |
+
|
50 |
+
// Draw name with outline
|
51 |
+
ctx.strokeText(nameText, canvasSize / 2, 100);
|
52 |
+
ctx.fillText(nameText, canvasSize / 2, 100);
|
53 |
+
|
54 |
+
// Draw level with outline
|
55 |
+
ctx.font = 'bold 36px Arial';
|
56 |
+
ctx.strokeText(levelText, canvasSize / 2, 150);
|
57 |
+
ctx.fillText(levelText, canvasSize / 2, 150);
|
58 |
+
|
59 |
+
// Load and draw watermark
|
60 |
+
const logoImg = await loadImage('/assets/snap_logo.png');
|
61 |
+
const logoSize = 120;
|
62 |
+
ctx.globalAlpha = 0.7; // Semi-transparent
|
63 |
+
ctx.drawImage(logoImg, canvasSize - logoSize - 20, canvasSize - logoSize - 20, logoSize, logoSize);
|
64 |
+
ctx.globalAlpha = 1.0;
|
65 |
+
|
66 |
+
// Get the image as blob
|
67 |
+
const blob = await canvasToBlob(canvas);
|
68 |
+
|
69 |
+
// Embed metadata in the blob
|
70 |
+
return embedPicletMetadata(blob, piclet);
|
71 |
+
}
|
72 |
+
|
73 |
+
/**
|
74 |
+
* Downloads a piclet card image
|
75 |
+
*/
|
76 |
+
export async function downloadPicletCard(piclet: PicletInstance, filename?: string): Promise<void> {
|
77 |
+
const blob = await generateShareableImage(piclet);
|
78 |
+
const url = URL.createObjectURL(blob);
|
79 |
+
|
80 |
+
const a = document.createElement('a');
|
81 |
+
a.href = url;
|
82 |
+
a.download = filename || `Piclet_${piclet.nickname || piclet.typeId}_Lv${piclet.level}.png`;
|
83 |
+
document.body.appendChild(a);
|
84 |
+
a.click();
|
85 |
+
document.body.removeChild(a);
|
86 |
+
|
87 |
+
URL.revokeObjectURL(url);
|
88 |
+
}
|
89 |
+
|
90 |
+
/**
|
91 |
+
* Helper to load an image
|
92 |
+
*/
|
93 |
+
function loadImage(src: string): Promise<HTMLImageElement> {
|
94 |
+
return new Promise((resolve, reject) => {
|
95 |
+
const img = new Image();
|
96 |
+
img.crossOrigin = 'anonymous';
|
97 |
+
img.onload = () => resolve(img);
|
98 |
+
img.onerror = reject;
|
99 |
+
img.src = src;
|
100 |
+
});
|
101 |
+
}
|
102 |
+
|
103 |
+
/**
|
104 |
+
* Convert canvas to blob
|
105 |
+
*/
|
106 |
+
function canvasToBlob(canvas: HTMLCanvasElement): Promise<Blob> {
|
107 |
+
return new Promise((resolve, reject) => {
|
108 |
+
canvas.toBlob((blob) => {
|
109 |
+
if (blob) resolve(blob);
|
110 |
+
else reject(new Error('Failed to create blob'));
|
111 |
+
}, 'image/png');
|
112 |
+
});
|
113 |
+
}
|
114 |
+
|
src/lib/services/picletMetadata.ts
ADDED
@@ -0,0 +1,280 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import type { PicletInstance } from '$lib/db/schema';
|
2 |
+
|
3 |
+
const METADATA_KEY = 'snaplings-piclet-v1';
|
4 |
+
|
5 |
+
interface PicletMetadata {
|
6 |
+
version: 1;
|
7 |
+
data: Omit<PicletInstance, 'id' | 'rosterPosition' | 'isInRoster' | 'caughtAt'>;
|
8 |
+
checksum?: string;
|
9 |
+
}
|
10 |
+
|
11 |
+
/**
|
12 |
+
* Extract piclet metadata from a PNG image
|
13 |
+
*/
|
14 |
+
export async function extractPicletMetadata(file: File): Promise<PicletInstance | null> {
|
15 |
+
try {
|
16 |
+
const arrayBuffer = await file.arrayBuffer();
|
17 |
+
const bytes = new Uint8Array(arrayBuffer);
|
18 |
+
|
19 |
+
// Check PNG signature
|
20 |
+
if (!isPNG(bytes)) {
|
21 |
+
return null;
|
22 |
+
}
|
23 |
+
|
24 |
+
// Find tEXt chunks
|
25 |
+
const chunks = parsePNGChunks(bytes);
|
26 |
+
const textChunk = chunks.find(chunk =>
|
27 |
+
chunk.type === 'tEXt' &&
|
28 |
+
chunk.keyword === METADATA_KEY
|
29 |
+
);
|
30 |
+
|
31 |
+
if (!textChunk || !textChunk.text) {
|
32 |
+
return null;
|
33 |
+
}
|
34 |
+
|
35 |
+
// Parse metadata
|
36 |
+
const metadata: PicletMetadata = JSON.parse(textChunk.text);
|
37 |
+
|
38 |
+
// Validate version
|
39 |
+
if (metadata.version !== 1) {
|
40 |
+
console.warn('Unsupported piclet metadata version:', metadata.version);
|
41 |
+
return null;
|
42 |
+
}
|
43 |
+
|
44 |
+
// Create PicletInstance from metadata
|
45 |
+
const piclet: PicletInstance = {
|
46 |
+
...metadata.data,
|
47 |
+
caughtAt: new Date(), // Use current date for import
|
48 |
+
isInRoster: false,
|
49 |
+
rosterPosition: undefined
|
50 |
+
};
|
51 |
+
|
52 |
+
return piclet;
|
53 |
+
} catch (error) {
|
54 |
+
console.error('Failed to extract piclet metadata:', error);
|
55 |
+
return null;
|
56 |
+
}
|
57 |
+
}
|
58 |
+
|
59 |
+
/**
|
60 |
+
* Embed piclet metadata into a PNG image
|
61 |
+
*/
|
62 |
+
export async function embedPicletMetadata(imageBlob: Blob, piclet: PicletInstance): Promise<Blob> {
|
63 |
+
const arrayBuffer = await imageBlob.arrayBuffer();
|
64 |
+
const bytes = new Uint8Array(arrayBuffer);
|
65 |
+
|
66 |
+
// Prepare metadata
|
67 |
+
const metadata: PicletMetadata = {
|
68 |
+
version: 1,
|
69 |
+
data: {
|
70 |
+
typeId: piclet.typeId,
|
71 |
+
nickname: piclet.nickname,
|
72 |
+
primaryTypeString: piclet.primaryTypeString,
|
73 |
+
secondaryTypeString: piclet.secondaryTypeString,
|
74 |
+
currentHp: piclet.maxHp, // Reset to full HP for sharing
|
75 |
+
maxHp: piclet.maxHp,
|
76 |
+
level: piclet.level,
|
77 |
+
xp: piclet.xp,
|
78 |
+
attack: piclet.attack,
|
79 |
+
defense: piclet.defense,
|
80 |
+
fieldAttack: piclet.fieldAttack,
|
81 |
+
fieldDefense: piclet.fieldDefense,
|
82 |
+
speed: piclet.speed,
|
83 |
+
baseHp: piclet.baseHp,
|
84 |
+
baseAttack: piclet.baseAttack,
|
85 |
+
baseDefense: piclet.baseDefense,
|
86 |
+
baseFieldAttack: piclet.baseFieldAttack,
|
87 |
+
baseFieldDefense: piclet.baseFieldDefense,
|
88 |
+
baseSpeed: piclet.baseSpeed,
|
89 |
+
moves: piclet.moves,
|
90 |
+
nature: piclet.nature,
|
91 |
+
bst: piclet.bst,
|
92 |
+
tier: piclet.tier,
|
93 |
+
role: piclet.role,
|
94 |
+
variance: piclet.variance,
|
95 |
+
imageUrl: piclet.imageUrl,
|
96 |
+
imageData: piclet.imageData,
|
97 |
+
imageCaption: piclet.imageCaption,
|
98 |
+
concept: piclet.concept,
|
99 |
+
imagePrompt: piclet.imagePrompt
|
100 |
+
}
|
101 |
+
};
|
102 |
+
|
103 |
+
// Create tEXt chunk
|
104 |
+
const textChunk = createTextChunk(METADATA_KEY, JSON.stringify(metadata));
|
105 |
+
|
106 |
+
// Insert chunk after IHDR
|
107 |
+
const newBytes = insertChunkAfterIHDR(bytes, textChunk);
|
108 |
+
|
109 |
+
return new Blob([newBytes], { type: 'image/png' });
|
110 |
+
}
|
111 |
+
|
112 |
+
/**
|
113 |
+
* Check if bytes represent a PNG file
|
114 |
+
*/
|
115 |
+
function isPNG(bytes: Uint8Array): boolean {
|
116 |
+
const pngSignature = [137, 80, 78, 71, 13, 10, 26, 10];
|
117 |
+
if (bytes.length < 8) return false;
|
118 |
+
|
119 |
+
for (let i = 0; i < 8; i++) {
|
120 |
+
if (bytes[i] !== pngSignature[i]) return false;
|
121 |
+
}
|
122 |
+
|
123 |
+
return true;
|
124 |
+
}
|
125 |
+
|
126 |
+
/**
|
127 |
+
* Parse PNG chunks
|
128 |
+
*/
|
129 |
+
function parsePNGChunks(bytes: Uint8Array): any[] {
|
130 |
+
const chunks = [];
|
131 |
+
let pos = 8; // Skip PNG signature
|
132 |
+
|
133 |
+
while (pos < bytes.length) {
|
134 |
+
// Read chunk length
|
135 |
+
const length = readUInt32BE(bytes, pos);
|
136 |
+
pos += 4;
|
137 |
+
|
138 |
+
// Read chunk type
|
139 |
+
const type = String.fromCharCode(...bytes.slice(pos, pos + 4));
|
140 |
+
pos += 4;
|
141 |
+
|
142 |
+
// Read chunk data
|
143 |
+
const data = bytes.slice(pos, pos + length);
|
144 |
+
pos += length;
|
145 |
+
|
146 |
+
// Skip CRC
|
147 |
+
pos += 4;
|
148 |
+
|
149 |
+
// Parse tEXt chunks
|
150 |
+
if (type === 'tEXt') {
|
151 |
+
const nullIndex = data.indexOf(0);
|
152 |
+
if (nullIndex !== -1) {
|
153 |
+
const keyword = String.fromCharCode(...data.slice(0, nullIndex));
|
154 |
+
const text = String.fromCharCode(...data.slice(nullIndex + 1));
|
155 |
+
chunks.push({ type, keyword, text });
|
156 |
+
}
|
157 |
+
} else {
|
158 |
+
chunks.push({ type, data });
|
159 |
+
}
|
160 |
+
|
161 |
+
if (type === 'IEND') break;
|
162 |
+
}
|
163 |
+
|
164 |
+
return chunks;
|
165 |
+
}
|
166 |
+
|
167 |
+
/**
|
168 |
+
* Create a tEXt chunk
|
169 |
+
*/
|
170 |
+
function createTextChunk(keyword: string, text: string): Uint8Array {
|
171 |
+
const keywordBytes = new TextEncoder().encode(keyword);
|
172 |
+
const textBytes = new TextEncoder().encode(text);
|
173 |
+
|
174 |
+
// Create chunk data: keyword + null + text
|
175 |
+
const data = new Uint8Array(keywordBytes.length + 1 + textBytes.length);
|
176 |
+
data.set(keywordBytes);
|
177 |
+
data[keywordBytes.length] = 0; // null separator
|
178 |
+
data.set(textBytes, keywordBytes.length + 1);
|
179 |
+
|
180 |
+
// Create full chunk: length + type + data + crc
|
181 |
+
const chunk = new Uint8Array(4 + 4 + data.length + 4);
|
182 |
+
|
183 |
+
// Length
|
184 |
+
writeUInt32BE(chunk, 0, data.length);
|
185 |
+
|
186 |
+
// Type: 'tEXt'
|
187 |
+
chunk[4] = 116; // t
|
188 |
+
chunk[5] = 69; // E
|
189 |
+
chunk[6] = 88; // X
|
190 |
+
chunk[7] = 116; // t
|
191 |
+
|
192 |
+
// Data
|
193 |
+
chunk.set(data, 8);
|
194 |
+
|
195 |
+
// CRC
|
196 |
+
const crc = calculateCRC(chunk.slice(4, 8 + data.length));
|
197 |
+
writeUInt32BE(chunk, 8 + data.length, crc);
|
198 |
+
|
199 |
+
return chunk;
|
200 |
+
}
|
201 |
+
|
202 |
+
/**
|
203 |
+
* Insert chunk after IHDR
|
204 |
+
*/
|
205 |
+
function insertChunkAfterIHDR(bytes: Uint8Array, newChunk: Uint8Array): Uint8Array {
|
206 |
+
// Find IHDR chunk end
|
207 |
+
let ihdrEnd = 8; // PNG signature
|
208 |
+
ihdrEnd += 4; // IHDR length
|
209 |
+
ihdrEnd += 4; // IHDR type
|
210 |
+
const ihdrLength = readUInt32BE(bytes, 8);
|
211 |
+
ihdrEnd += ihdrLength; // IHDR data
|
212 |
+
ihdrEnd += 4; // IHDR CRC
|
213 |
+
|
214 |
+
// Create new array
|
215 |
+
const result = new Uint8Array(bytes.length + newChunk.length);
|
216 |
+
|
217 |
+
// Copy up to IHDR end
|
218 |
+
result.set(bytes.slice(0, ihdrEnd));
|
219 |
+
|
220 |
+
// Insert new chunk
|
221 |
+
result.set(newChunk, ihdrEnd);
|
222 |
+
|
223 |
+
// Copy rest
|
224 |
+
result.set(bytes.slice(ihdrEnd), ihdrEnd + newChunk.length);
|
225 |
+
|
226 |
+
return result;
|
227 |
+
}
|
228 |
+
|
229 |
+
/**
|
230 |
+
* Read 32-bit unsigned integer (big endian)
|
231 |
+
*/
|
232 |
+
function readUInt32BE(bytes: Uint8Array, offset: number): number {
|
233 |
+
return (bytes[offset] << 24) |
|
234 |
+
(bytes[offset + 1] << 16) |
|
235 |
+
(bytes[offset + 2] << 8) |
|
236 |
+
bytes[offset + 3];
|
237 |
+
}
|
238 |
+
|
239 |
+
/**
|
240 |
+
* Write 32-bit unsigned integer (big endian)
|
241 |
+
*/
|
242 |
+
function writeUInt32BE(bytes: Uint8Array, offset: number, value: number): void {
|
243 |
+
bytes[offset] = (value >>> 24) & 0xff;
|
244 |
+
bytes[offset + 1] = (value >>> 16) & 0xff;
|
245 |
+
bytes[offset + 2] = (value >>> 8) & 0xff;
|
246 |
+
bytes[offset + 3] = value & 0xff;
|
247 |
+
}
|
248 |
+
|
249 |
+
/**
|
250 |
+
* Calculate CRC32 for PNG chunk
|
251 |
+
*/
|
252 |
+
function calculateCRC(bytes: Uint8Array): number {
|
253 |
+
const crcTable = getCRCTable();
|
254 |
+
let crc = 0xffffffff;
|
255 |
+
|
256 |
+
for (let i = 0; i < bytes.length; i++) {
|
257 |
+
crc = crcTable[(crc ^ bytes[i]) & 0xff] ^ (crc >>> 8);
|
258 |
+
}
|
259 |
+
|
260 |
+
return crc ^ 0xffffffff;
|
261 |
+
}
|
262 |
+
|
263 |
+
/**
|
264 |
+
* Get CRC table (cached)
|
265 |
+
*/
|
266 |
+
let crcTable: Uint32Array | null = null;
|
267 |
+
function getCRCTable(): Uint32Array {
|
268 |
+
if (crcTable) return crcTable;
|
269 |
+
|
270 |
+
crcTable = new Uint32Array(256);
|
271 |
+
for (let i = 0; i < 256; i++) {
|
272 |
+
let c = i;
|
273 |
+
for (let j = 0; j < 8; j++) {
|
274 |
+
c = (c & 1) ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
275 |
+
}
|
276 |
+
crcTable[i] = c;
|
277 |
+
}
|
278 |
+
|
279 |
+
return crcTable;
|
280 |
+
}
|