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 +3 -0
- package/LICENSE +21 -0
- package/README.md +156 -0
- package/dist/AssetPackWriter.d.ts +14 -0
- package/dist/AssetPackWriter.js +41 -0
- package/dist/BatchGenerator.d.ts +28 -0
- package/dist/BatchGenerator.js +214 -0
- package/dist/ImageProcessor.d.ts +15 -0
- package/dist/ImageProcessor.js +126 -0
- package/dist/OpenAIImageClient.d.ts +38 -0
- package/dist/OpenAIImageClient.js +335 -0
- package/dist/PixelArtPostProcessor.d.ts +33 -0
- package/dist/PixelArtPostProcessor.js +404 -0
- package/dist/StyleExplorer.d.ts +24 -0
- package/dist/StyleExplorer.js +134 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +228 -0
- package/dist/config.d.ts +8 -0
- package/dist/config.js +91 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/types.d.ts +54 -0
- package/dist/types.js +1 -0
- package/package.json +58 -0
package/.env.example
ADDED
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
|
+
}
|