restyle-sprites 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env.example ADDED
@@ -0,0 +1,3 @@
1
+ GEMINI_API_KEY=
2
+ OPENAI_API_KEY=
3
+ GEMINI_IMAGE_MODEL=
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,156 @@
1
+ # restyle-sprites
2
+
3
+ AI-powered sprite restyling pipeline for generating cohesive game asset packs from legacy source sprites.
4
+
5
+ It focuses on one hard problem: keep source geometry and gameplay readability while applying a new style.
6
+
7
+ ## Features
8
+
9
+ - Restyle pipeline: source asset + style reference + prompt
10
+ - Interactive style reference exploration (`explore`)
11
+ - Batch generation for image and spritesheet assets
12
+ - Pixel-art post-processing (palette quantization, alpha cleanup, nearest-neighbor resize)
13
+ - Legacy colorkey stripping support for opaque source files (for example old BMP assets)
14
+ - JSON and YAML config support
15
+ - Provider setup: Gemini primary, OpenAI fallback
16
+ - Engine-agnostic metadata support (`metadata` field per asset)
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ npm install @yesterday-ai/restyle-sprites
22
+ ```
23
+
24
+ or with pnpm:
25
+
26
+ ```bash
27
+ pnpm add @yesterday-ai/restyle-sprites
28
+ ```
29
+
30
+ ## Quick Start
31
+
32
+ 1. Create a config file (`restyle.config.json` or `.yaml`).
33
+ 2. Add API keys to `.env` in the same directory as the config.
34
+ 3. Generate a style reference.
35
+ 4. Generate the full pack.
36
+
37
+ ### Environment variables
38
+
39
+ ```bash
40
+ GEMINI_API_KEY=...
41
+ OPENAI_API_KEY=...
42
+ GEMINI_IMAGE_MODEL=gemini-3.1-flash-image-preview
43
+ ```
44
+
45
+ `OPENAI_API_KEY` is optional, used as fallback.
46
+
47
+ ## CLI
48
+
49
+ ```bash
50
+ restyle-sprites explore --name my-pack --config ./restyle.config.json
51
+ restyle-sprites explore-once --name my-pack --config ./restyle.config.json --prompt "pixel-art fantasy"
52
+ restyle-sprites generate --name my-pack --config ./restyle.config.json
53
+ restyle-sprites init-default --config ./restyle.config.json
54
+ ```
55
+
56
+ ### Commands
57
+
58
+ - `explore`: interactive style preview loop and approval flow
59
+ - `explore-once`: one-shot style reference generation
60
+ - `generate`: generate all configured assets into one pack
61
+ - `init-default`: convert source assets to a baseline `default` pack
62
+
63
+ ## Config format
64
+
65
+ All paths are resolved relative to the config file directory.
66
+
67
+ ```json
68
+ {
69
+ "outputDir": "./public/assets/packs",
70
+ "defaultActivePack": "default",
71
+ "sampleSprites": [
72
+ "./public/assets/sprites/hero.png",
73
+ "./public/assets/sprites/tree.png"
74
+ ],
75
+ "assets": [
76
+ {
77
+ "id": "hero_walk",
78
+ "sourceFile": "./public/assets/sprites/hero_walk.png",
79
+ "outputFile": "sprites/hero_walk.png",
80
+ "kind": "spritesheet",
81
+ "category": "character",
82
+ "width": 96,
83
+ "height": 32,
84
+ "frameWidth": 32,
85
+ "frameHeight": 32,
86
+ "frameCount": 3,
87
+ "frameDirection": "horizontal",
88
+ "promptHint": "Hero walk cycle, three frames, preserve silhouette.",
89
+ "metadata": {
90
+ "engineKey": "hero_walk"
91
+ }
92
+ }
93
+ ]
94
+ }
95
+ ```
96
+
97
+ ## Programmatic usage
98
+
99
+ ```ts
100
+ import { loadConfig, BatchGenerator, ImageProcessor, OpenAIImageClient, PixelArtPostProcessor } from '@yesterday-ai/restyle-sprites';
101
+ ```
102
+
103
+ For a full working setup, check:
104
+
105
+ - `examples/restyle.config.json`
106
+ - `examples/restyle.config.yaml`
107
+
108
+ ## CI And Security
109
+
110
+ The package includes a GitHub Actions workflow at `.github/workflows/ci.yml` with:
111
+
112
+ - build + typecheck (`pnpm typecheck`, `pnpm build`)
113
+ - secret detection using Gitleaks on every push and pull request
114
+
115
+ Release automation is configured with:
116
+
117
+ - `.github/workflows/release.yml` using Changesets
118
+ - automatic release PRs with version bumps and changelog updates
119
+ - npm publish to the public registry using `NPM_TOKEN`
120
+
121
+ Detailed release runbook: `RELEASING.md`
122
+
123
+ ## PR Blocking Policy
124
+
125
+ Use GitHub branch protection (or rulesets) on `main` and require these status checks:
126
+
127
+ - `Build And Typecheck`
128
+ - `Secret Detection (Gitleaks)`
129
+
130
+ This ensures no PR can be merged unless CI is green.
131
+
132
+ ## Changelog generation
133
+
134
+ This package uses Changesets for versioning and changelog generation.
135
+
136
+ Basic flow:
137
+
138
+ 1. Add a changeset in your PR:
139
+ ```bash
140
+ pnpm changeset
141
+ ```
142
+ 2. Merge PR to `main`.
143
+ 3. Release workflow opens/updates a release PR with:
144
+ - bumped version in `package.json`
145
+ - generated `CHANGELOG.md`
146
+ 4. Merge release PR to publish to npm.
147
+
148
+ Required repository secret:
149
+
150
+ - `NPM_TOKEN` (npm automation token with publish permissions for `@yesterday-ai`)
151
+
152
+ ## Notes
153
+
154
+ - For very small sprites, the pipeline upscales before generation and downsamples with nearest-neighbor.
155
+ - `metadata` is copied through to generated manifest entries unchanged.
156
+ - Output pack manifests are written as `<outputDir>/<packName>/manifest.json`, and `<outputDir>/index.json` is refreshed after each command.
@@ -0,0 +1,14 @@
1
+ import { AssetPackManifest, AssetPackManifestEntry } from './types.js';
2
+ export declare class AssetPackWriter {
3
+ writeManifest(params: {
4
+ packDir: string;
5
+ packName: string;
6
+ description: string;
7
+ styleReferenceImage?: string;
8
+ assets: AssetPackManifestEntry[];
9
+ }): Promise<AssetPackManifest>;
10
+ updatePackIndex(params: {
11
+ packsRootDir: string;
12
+ activePack: string;
13
+ }): Promise<void>;
14
+ }
@@ -0,0 +1,41 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ export class AssetPackWriter {
4
+ async writeManifest(params) {
5
+ const manifest = {
6
+ name: params.packName,
7
+ description: params.description,
8
+ createdAt: new Date().toISOString(),
9
+ styleReferenceImage: params.styleReferenceImage,
10
+ assets: params.assets,
11
+ };
12
+ const manifestPath = path.join(params.packDir, 'manifest.json');
13
+ await fs.mkdir(params.packDir, { recursive: true });
14
+ await fs.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
15
+ return manifest;
16
+ }
17
+ async updatePackIndex(params) {
18
+ const indexPath = path.join(params.packsRootDir, 'index.json');
19
+ const dirEntries = await fs.readdir(params.packsRootDir, { withFileTypes: true });
20
+ const packs = (await Promise.all(dirEntries
21
+ .filter((entry) => entry.isDirectory())
22
+ .map(async (entry) => {
23
+ const manifestPath = path.join(params.packsRootDir, entry.name, 'manifest.json');
24
+ try {
25
+ await fs.access(manifestPath);
26
+ return {
27
+ name: entry.name,
28
+ manifest: `${entry.name}/manifest.json`,
29
+ };
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }))).filter((item) => Boolean(item));
35
+ const content = {
36
+ activePack: packs.some((pack) => pack.name === params.activePack) ? params.activePack : 'default',
37
+ packs: packs.sort((a, b) => a.name.localeCompare(b.name)),
38
+ };
39
+ await fs.writeFile(indexPath, `${JSON.stringify(content, null, 2)}\n`, 'utf8');
40
+ }
41
+ }
@@ -0,0 +1,28 @@
1
+ import { OpenAIImageClient } from './OpenAIImageClient.js';
2
+ import { ImageProcessor } from './ImageProcessor.js';
3
+ import { PixelArtPostProcessor } from './PixelArtPostProcessor.js';
4
+ import { AssetDefinition, AssetPackManifestEntry } from './types.js';
5
+ export declare class BatchGenerator {
6
+ private readonly openAI;
7
+ private readonly imageProcessor;
8
+ private readonly postProcessor;
9
+ private static readonly MAX_RENDER_ATTEMPTS;
10
+ private static readonly MAX_STYLE_PROMPT_CHARS;
11
+ private static readonly MAX_ASSET_HINT_CHARS;
12
+ constructor(openAI: OpenAIImageClient, imageProcessor: ImageProcessor, postProcessor: PixelArtPostProcessor);
13
+ generatePackAssets(params: {
14
+ workspaceRoot: string;
15
+ assets: AssetDefinition[];
16
+ styleReferencePath: string;
17
+ outputPackDir: string;
18
+ stylePrompt: string;
19
+ }): Promise<AssetPackManifestEntry[]>;
20
+ private fileExists;
21
+ private isModerationBlocked;
22
+ private buildFallbackPrompt;
23
+ private renderWithRetry;
24
+ private buildPrompt;
25
+ private buildPaletteConstraint;
26
+ private getCategoryPromptContext;
27
+ private compactText;
28
+ }
@@ -0,0 +1,214 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ export class BatchGenerator {
4
+ openAI;
5
+ imageProcessor;
6
+ postProcessor;
7
+ static MAX_RENDER_ATTEMPTS = 2;
8
+ static MAX_STYLE_PROMPT_CHARS = 400;
9
+ static MAX_ASSET_HINT_CHARS = 400;
10
+ constructor(openAI, imageProcessor, postProcessor) {
11
+ this.openAI = openAI;
12
+ this.imageProcessor = imageProcessor;
13
+ this.postProcessor = postProcessor;
14
+ }
15
+ async generatePackAssets(params) {
16
+ const generated = [];
17
+ for (const asset of params.assets) {
18
+ const absoluteSourcePath = path.join(params.workspaceRoot, asset.sourceFile);
19
+ const absoluteOutputPath = path.join(params.outputPackDir, asset.outputFile);
20
+ const renderSize = this.imageProcessor.chooseRenderSize(asset.width, asset.height);
21
+ if (await this.fileExists(absoluteOutputPath)) {
22
+ console.log(`Skipping ${asset.id} (already generated).`);
23
+ generated.push({
24
+ id: asset.id,
25
+ metadata: asset.metadata,
26
+ file: asset.outputFile,
27
+ kind: asset.kind,
28
+ width: asset.width,
29
+ height: asset.height,
30
+ frameWidth: asset.kind === 'spritesheet' ? asset.frameWidth : undefined,
31
+ frameHeight: asset.kind === 'spritesheet' ? asset.frameHeight : undefined,
32
+ frameCount: asset.kind === 'spritesheet' ? asset.frameCount : undefined,
33
+ frameDirection: asset.kind === 'spritesheet' ? asset.frameDirection : undefined,
34
+ });
35
+ continue;
36
+ }
37
+ console.log(`Generating ${asset.id} ...`);
38
+ try {
39
+ const sourceAssetRaw = await this.imageProcessor.readAsPngBuffer(absoluteSourcePath);
40
+ const sourceAsset = await this.postProcessor.stripLegacyBackground(sourceAssetRaw);
41
+ const sourcePalette = await this.postProcessor.extractPalette(sourceAsset);
42
+ if (asset.kind === 'image') {
43
+ const upscaledSource = await this.imageProcessor.upscaleForGeneration(sourceAsset, renderSize);
44
+ const rendered = await this.renderWithRetry(async (prompt) => this.openAI.restyleAssetBuffer({
45
+ sourceAsset: upscaledSource,
46
+ styleReferencePath: params.styleReferencePath,
47
+ prompt,
48
+ renderSize,
49
+ }), asset.promptHint, params.stylePrompt, `${asset.width}x${asset.height}`, sourcePalette, asset.category);
50
+ const processed = await this.postProcessor.process(rendered, asset.width, asset.height, {
51
+ sourceReference: sourceAsset,
52
+ category: asset.category,
53
+ });
54
+ await this.imageProcessor.writeBuffer(processed, absoluteOutputPath);
55
+ }
56
+ else {
57
+ const sourceFramesRaw = await this.imageProcessor.extractSpriteFrames(absoluteSourcePath, asset);
58
+ const sourceFrames = await Promise.all(sourceFramesRaw.map((frame) => this.postProcessor.stripLegacyBackground(frame)));
59
+ const styledFrames = [];
60
+ for (let frameIndex = 0; frameIndex < sourceFrames.length; frameIndex += 1) {
61
+ const upscaledFrame = await this.imageProcessor.upscaleForGeneration(sourceFrames[frameIndex], renderSize);
62
+ const frameHint = `${asset.promptHint} Frame ${frameIndex + 1} of ${sourceFrames.length}. Keep animation continuity.`;
63
+ const frame = await this.renderWithRetry(async (prompt) => this.openAI.restyleAssetBuffer({
64
+ sourceAsset: upscaledFrame,
65
+ styleReferencePath: params.styleReferencePath,
66
+ prompt,
67
+ renderSize,
68
+ }), frameHint, params.stylePrompt, `${asset.frameWidth}x${asset.frameHeight}`, sourcePalette, asset.category);
69
+ const processedFrame = await this.postProcessor.process(frame, asset.frameWidth, asset.frameHeight, {
70
+ sourceReference: sourceFrames[frameIndex],
71
+ category: asset.category,
72
+ });
73
+ styledFrames.push(processedFrame);
74
+ }
75
+ await this.imageProcessor.stitchVerticalSpriteSheet(styledFrames, asset.frameWidth, asset.frameHeight, absoluteOutputPath);
76
+ }
77
+ }
78
+ catch (error) {
79
+ if (!this.isModerationBlocked(error)) {
80
+ throw error;
81
+ }
82
+ console.warn(`Moderation blocked ${asset.id}; using source fallback for this asset.`);
83
+ await this.imageProcessor.convertToPng(absoluteSourcePath, absoluteOutputPath);
84
+ }
85
+ generated.push({
86
+ id: asset.id,
87
+ metadata: asset.metadata,
88
+ file: asset.outputFile,
89
+ kind: asset.kind,
90
+ width: asset.width,
91
+ height: asset.height,
92
+ frameWidth: asset.kind === 'spritesheet' ? asset.frameWidth : undefined,
93
+ frameHeight: asset.kind === 'spritesheet' ? asset.frameHeight : undefined,
94
+ frameCount: asset.kind === 'spritesheet' ? asset.frameCount : undefined,
95
+ frameDirection: asset.kind === 'spritesheet' ? asset.frameDirection : undefined,
96
+ });
97
+ }
98
+ return generated;
99
+ }
100
+ async fileExists(filePath) {
101
+ try {
102
+ await fs.access(filePath);
103
+ return true;
104
+ }
105
+ catch {
106
+ return false;
107
+ }
108
+ }
109
+ isModerationBlocked(error) {
110
+ if (!(error instanceof Error)) {
111
+ return false;
112
+ }
113
+ const message = error.message.toLowerCase();
114
+ return message.includes('moderation_blocked') || message.includes('rejected by the safety system');
115
+ }
116
+ buildFallbackPrompt(assetHint, targetSize, sourcePaletteHex, category) {
117
+ const shortHint = this.compactText(assetHint, BatchGenerator.MAX_ASSET_HINT_CHARS);
118
+ const categoryContext = this.getCategoryPromptContext(category);
119
+ const paletteConstraint = this.buildPaletteConstraint(sourcePaletteHex);
120
+ return [
121
+ 'Image 1 is the source sprite, upscaled with nearest-neighbor so the original pixel grid is clear.',
122
+ 'Image 2 is the approved style reference.',
123
+ 'TASK: Redraw Image 1 in the style of Image 2.',
124
+ categoryContext,
125
+ `Asset details: ${shortHint}.`,
126
+ 'PRESERVE from Image 1: exact silhouette, proportions, object identity, pose, orientation, framing, and aspect ratio.',
127
+ 'CHANGE to match Image 2: palette, shading style, edge treatment, texture mood, and color harmony.',
128
+ paletteConstraint,
129
+ 'PIXEL ART CONSTRAINTS: hard edges only, no anti-aliasing, no blur, no gradients, no soft shadows, no sub-pixel rendering, no dithering.',
130
+ 'Each visible pixel must be a clean square with a uniform color value.',
131
+ 'The subject must fill at least 80% of the canvas and remain centered.',
132
+ `The output will be downscaled to ${targetSize} with nearest-neighbor, so detail must survive tiny scale.`,
133
+ 'OUTPUT: one centered sprite only, transparent PNG background, no text, no borders, no extra objects.',
134
+ ].join(' ');
135
+ }
136
+ async renderWithRetry(renderer, assetHint, stylePrompt, targetSize, sourcePaletteHex, category) {
137
+ const prompts = [
138
+ this.buildPrompt(assetHint, stylePrompt, targetSize, sourcePaletteHex, category),
139
+ this.buildFallbackPrompt(assetHint, targetSize, sourcePaletteHex, category),
140
+ ];
141
+ let lastError;
142
+ for (let attempt = 0; attempt < BatchGenerator.MAX_RENDER_ATTEMPTS; attempt += 1) {
143
+ try {
144
+ return await renderer(prompts[attempt] ?? prompts[prompts.length - 1]);
145
+ }
146
+ catch (error) {
147
+ lastError = error;
148
+ if (!this.isModerationBlocked(error) || attempt >= BatchGenerator.MAX_RENDER_ATTEMPTS - 1) {
149
+ throw error;
150
+ }
151
+ console.warn(`Retrying with safer prompt after moderation block (attempt ${attempt + 2}).`);
152
+ }
153
+ }
154
+ throw lastError instanceof Error ? lastError : new Error('Asset rendering failed.');
155
+ }
156
+ buildPrompt(assetHint, stylePrompt, targetSize, sourcePaletteHex, category) {
157
+ const shortStyle = this.compactText(stylePrompt, BatchGenerator.MAX_STYLE_PROMPT_CHARS);
158
+ const shortHint = this.compactText(assetHint, BatchGenerator.MAX_ASSET_HINT_CHARS);
159
+ const categoryContext = this.getCategoryPromptContext(category);
160
+ const paletteConstraint = this.buildPaletteConstraint(sourcePaletteHex);
161
+ return [
162
+ 'Image 1 is the SOURCE sprite, upscaled with nearest-neighbor so each original pixel appears as a clean square.',
163
+ 'Image 2 is the approved STYLE REFERENCE for target art direction.',
164
+ 'TASK: Redraw Image 1 in the style of Image 2.',
165
+ categoryContext,
166
+ `STYLE DIRECTION: ${shortStyle}.`,
167
+ `Asset details: ${shortHint}.`,
168
+ 'PRESERVE from Image 1: exact silhouette and proportions (pixel-grid aligned), object identity, recognizable features, pose, orientation, framing, and aspect ratio.',
169
+ 'CHANGE to match Image 2: color palette, shading approach, surface texture, edge language, and overall visual mood.',
170
+ paletteConstraint,
171
+ 'PIXEL ART CONSTRAINTS:',
172
+ '- Hard pixel edges only. No anti-aliasing, no blur, no gradients, no soft shadows, no sub-pixel rendering, no dithering.',
173
+ '- Maximum 16-24 distinct colors.',
174
+ '- Keep pixel-grid consistency and block clarity with a 16-bit SNES sprite aesthetic.',
175
+ '- Each visible pixel must be a clean square with a uniform color value.',
176
+ '- The subject must fill at least 80% of the canvas and remain centered.',
177
+ `- Output must remain readable after nearest-neighbor downscale to ${targetSize}.`,
178
+ 'OUTPUT: one centered sprite, transparent PNG background, no text, no border, no extra elements.',
179
+ ].join(' ');
180
+ }
181
+ buildPaletteConstraint(sourcePaletteHex) {
182
+ if (sourcePaletteHex.length === 0) {
183
+ return 'Use a compact retro palette with 16-24 colors max.';
184
+ }
185
+ return `Use ONLY these colors (or very close variations): ${sourcePaletteHex.join(', ')}.`;
186
+ }
187
+ getCategoryPromptContext(category) {
188
+ switch (category) {
189
+ case 'character':
190
+ return 'Category context: animation sprite frame. Maintain character identity, stable body proportions, and continuity across frames.';
191
+ case 'resource':
192
+ return 'Category context: top-down resource node. Keep a strong silhouette and immediate recognition at tiny scale.';
193
+ case 'effect':
194
+ return 'Category context: gameplay VFX icon. Keep lightweight form and high contrast over varied backgrounds.';
195
+ case 'prop':
196
+ return 'Category context: world prop. Preserve distinct shape language and clear readability across camera zoom levels.';
197
+ case 'icon':
198
+ return 'Category context: marker icon. Prioritize maximum readability at 16x16.';
199
+ case 'scene':
200
+ return 'Category context: full scene artwork. Preserve overall composition, layer depth, and camera perspective.';
201
+ case 'font':
202
+ return 'Category context: bitmap font strip. Preserve exact glyph grid, spacing, baseline, and character order.';
203
+ default:
204
+ return 'Category context: game sprite. Keep gameplay readability and clear shape separation.';
205
+ }
206
+ }
207
+ compactText(value, maxChars) {
208
+ const normalized = value.replace(/\s+/g, ' ').trim();
209
+ if (normalized.length <= maxChars) {
210
+ return normalized;
211
+ }
212
+ return `${normalized.slice(0, maxChars - 3)}...`;
213
+ }
214
+ }
@@ -0,0 +1,15 @@
1
+ import { SpriteSheetAssetDefinition } from './types.js';
2
+ export type ImageRenderSize = '256x256' | '512x512' | '1024x1024';
3
+ export declare class ImageProcessor {
4
+ private parseRenderSize;
5
+ chooseRenderSize(width: number, height: number): ImageRenderSize;
6
+ private loadAsPngBufferWithFallback;
7
+ ensureDir(dirPath: string): Promise<void>;
8
+ readAsPngBuffer(sourcePath: string): Promise<Buffer>;
9
+ convertToPng(sourcePath: string, outputPath: string): Promise<void>;
10
+ writeBuffer(buffer: Buffer, outputPath: string): Promise<void>;
11
+ fitToExactSize(input: Buffer | string, width: number, height: number, outputPath?: string): Promise<Buffer>;
12
+ upscaleForGeneration(source: Buffer, renderSize: ImageRenderSize): Promise<Buffer>;
13
+ extractSpriteFrames(sourcePath: string, spriteSheet: SpriteSheetAssetDefinition): Promise<Buffer[]>;
14
+ stitchVerticalSpriteSheet(frames: Buffer[], frameWidth: number, frameHeight: number, outputPath: string): Promise<void>;
15
+ }
@@ -0,0 +1,126 @@
1
+ import fs from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { execFile } from 'node:child_process';
5
+ import { promisify } from 'node:util';
6
+ import sharp from 'sharp';
7
+ const execFileAsync = promisify(execFile);
8
+ export class ImageProcessor {
9
+ parseRenderSize(renderSize) {
10
+ return Number.parseInt(renderSize.split('x')[0] ?? '1024', 10);
11
+ }
12
+ chooseRenderSize(width, height) {
13
+ const maxDimension = Math.max(width, height);
14
+ if (maxDimension <= 20) {
15
+ return '256x256';
16
+ }
17
+ if (maxDimension <= 96) {
18
+ return '512x512';
19
+ }
20
+ return '1024x1024';
21
+ }
22
+ async loadAsPngBufferWithFallback(sourcePath) {
23
+ try {
24
+ return await sharp(sourcePath).png().toBuffer();
25
+ }
26
+ catch {
27
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'restyle-sprites-'));
28
+ const tempPngPath = path.join(tempDir, 'source.png');
29
+ try {
30
+ await execFileAsync('sips', ['-s', 'format', 'png', sourcePath, '--out', tempPngPath]);
31
+ return await fs.readFile(tempPngPath);
32
+ }
33
+ finally {
34
+ await fs.rm(tempDir, { recursive: true, force: true });
35
+ }
36
+ }
37
+ }
38
+ async ensureDir(dirPath) {
39
+ await fs.mkdir(dirPath, { recursive: true });
40
+ }
41
+ async readAsPngBuffer(sourcePath) {
42
+ return this.loadAsPngBufferWithFallback(sourcePath);
43
+ }
44
+ async convertToPng(sourcePath, outputPath) {
45
+ await this.ensureDir(path.dirname(outputPath));
46
+ try {
47
+ await sharp(sourcePath).png().toFile(outputPath);
48
+ }
49
+ catch {
50
+ await execFileAsync('sips', ['-s', 'format', 'png', sourcePath, '--out', outputPath]);
51
+ }
52
+ }
53
+ async writeBuffer(buffer, outputPath) {
54
+ await this.ensureDir(path.dirname(outputPath));
55
+ await fs.writeFile(outputPath, buffer);
56
+ }
57
+ async fitToExactSize(input, width, height, outputPath) {
58
+ const normalizedInput = typeof input === 'string' ? await this.loadAsPngBufferWithFallback(input) : input;
59
+ const buffer = await sharp(normalizedInput)
60
+ .resize(width, height, {
61
+ fit: 'contain',
62
+ position: 'centre',
63
+ background: { r: 0, g: 0, b: 0, alpha: 0 },
64
+ kernel: sharp.kernel.nearest,
65
+ })
66
+ .png()
67
+ .toBuffer();
68
+ if (outputPath) {
69
+ await this.ensureDir(path.dirname(outputPath));
70
+ await fs.writeFile(outputPath, buffer);
71
+ }
72
+ return buffer;
73
+ }
74
+ async upscaleForGeneration(source, renderSize) {
75
+ const targetSize = this.parseRenderSize(renderSize);
76
+ return sharp(source)
77
+ .resize(targetSize, targetSize, {
78
+ fit: 'contain',
79
+ position: 'centre',
80
+ background: { r: 0, g: 0, b: 0, alpha: 0 },
81
+ kernel: sharp.kernel.nearest,
82
+ })
83
+ .png()
84
+ .toBuffer();
85
+ }
86
+ async extractSpriteFrames(sourcePath, spriteSheet) {
87
+ const normalizedSource = await this.loadAsPngBufferWithFallback(sourcePath);
88
+ const frames = [];
89
+ for (let frameIndex = 0; frameIndex < spriteSheet.frameCount; frameIndex += 1) {
90
+ const left = spriteSheet.frameDirection === 'horizontal' ? frameIndex * spriteSheet.frameWidth : 0;
91
+ const top = spriteSheet.frameDirection === 'vertical' ? frameIndex * spriteSheet.frameHeight : 0;
92
+ const frameBuffer = await sharp(normalizedSource)
93
+ .extract({
94
+ left,
95
+ top,
96
+ width: spriteSheet.frameWidth,
97
+ height: spriteSheet.frameHeight,
98
+ })
99
+ .png()
100
+ .toBuffer();
101
+ frames.push(frameBuffer);
102
+ }
103
+ return frames;
104
+ }
105
+ async stitchVerticalSpriteSheet(frames, frameWidth, frameHeight, outputPath) {
106
+ const canvasHeight = frameHeight * frames.length;
107
+ const composite = frames.map((input, index) => ({
108
+ input,
109
+ left: 0,
110
+ top: index * frameHeight,
111
+ }));
112
+ const output = await sharp({
113
+ create: {
114
+ width: frameWidth,
115
+ height: canvasHeight,
116
+ channels: 4,
117
+ background: { r: 0, g: 0, b: 0, alpha: 0 },
118
+ },
119
+ })
120
+ .composite(composite)
121
+ .png()
122
+ .toBuffer();
123
+ await this.ensureDir(path.dirname(outputPath));
124
+ await fs.writeFile(outputPath, output);
125
+ }
126
+ }
@@ -0,0 +1,38 @@
1
+ import type { ImageRenderSize } from './ImageProcessor.js';
2
+ export interface OpenAIImageClientOptions {
3
+ model?: 'gpt-image-1.5' | 'gpt-image-1' | 'gpt-image-1-mini';
4
+ }
5
+ export interface StyleReferenceResult {
6
+ image: Buffer;
7
+ revisedPrompt?: string;
8
+ responseId?: string;
9
+ }
10
+ export declare class OpenAIImageClient {
11
+ private static readonly STYLE_REFERENCE_MAX_ATTEMPTS;
12
+ private readonly client;
13
+ private readonly model;
14
+ private readonly geminiModel;
15
+ constructor(options?: OpenAIImageClientOptions);
16
+ private getOpenAIModelCandidates;
17
+ generateStyleReference(prompt: string, inspirationImagePath?: string): Promise<StyleReferenceResult>;
18
+ restyleAsset(params: {
19
+ sourceAssetPath: string;
20
+ styleReferencePath: string;
21
+ prompt: string;
22
+ renderSize?: ImageRenderSize;
23
+ }): Promise<Buffer>;
24
+ restyleAssetBuffer(params: {
25
+ sourceAsset: Buffer;
26
+ styleReferencePath: string;
27
+ prompt: string;
28
+ renderSize?: ImageRenderSize;
29
+ }): Promise<Buffer>;
30
+ private generateWithOpenAI;
31
+ private generateWithOpenAIRestyle;
32
+ private shouldFallbackToOpenAI;
33
+ private isModelNotFound;
34
+ private generateWithGemini;
35
+ private sanitizeStyleReferenceBuffer;
36
+ private analyzeStyleReferenceQuality;
37
+ private isNeutralCheckerColor;
38
+ }