初始化项目版本
This commit is contained in:
73
.agents/skills/screenshot-to-prototype/SKILL.md
Normal file
73
.agents/skills/screenshot-to-prototype/SKILL.md
Normal file
@@ -0,0 +1,73 @@
|
||||
---
|
||||
name: screenshot-to-prototype
|
||||
description: Use only when 用户明确要求把本地截图、设计稿或高保真界面图还原成 Axhub Make client 可运行原型;或显式调用 $screenshot-to-prototype。仅提供图片作为素材、参考图、需求图或风格上下文时不要使用。
|
||||
---
|
||||
|
||||
# Screenshot To Prototype
|
||||
|
||||
用本地截图/设计稿还原 client 可运行原型:先提取必要素材,再写 React/CSS,最后做真实运行截图回归。正文保持中文、简洁。
|
||||
|
||||
## 退出规则
|
||||
|
||||
任一条件不满足就停止:
|
||||
|
||||
- 用户未提供源图。
|
||||
- 必须能获取源图的本地路径;如果源图没有本地路径,必须停止。
|
||||
- 图片生成能力可以来自 `ui-design-image`、系统 `imagegen`、ACP UI 图片 MCP、等价图片 MCP,或 Agent 图片配置。
|
||||
- 不能只因当前工具面板没有直接暴露图片生成工具就停止;停止前必须主动检查这些通道。
|
||||
- 确认所有图片生成通道都不可用或都不支持传入本地图片路径时,才停止。
|
||||
- 启动实现前必须确认存在视觉回归工具。
|
||||
- 视觉回归工具必须能获取产物真实运行截图;如果无法获取真实运行截图,必须停止。
|
||||
- 用户只是提供图片作为需求、内容、素材、风格上下文或普通参考图时,必须停止。
|
||||
- 普通建站、URL 克隆、主题提取、单纯图片生成不要使用本技能。
|
||||
|
||||
## 路径
|
||||
|
||||
所有路径都以 client 包根目录为基准,文档里不要写本机绝对路径、平台路径或外层仓库路径。
|
||||
|
||||
- 原型:`src/prototypes/<slug>/`
|
||||
- 素材:`src/prototypes/<slug>/assets/`
|
||||
- 素材清单:`src/prototypes/<slug>/assets/asset-manifest.json`
|
||||
- 临时文件:`.local/screenshot-to-prototype/<slug>/`
|
||||
|
||||
## 流程
|
||||
|
||||
1. 先应用退出规则,确认用户明确要求把截图/设计稿还原成可运行原型,并确认源图本地路径、图片生成通道、视觉回归工具。
|
||||
2. 若图片生成通道不明确,先按 `ui-design-image` 的工作流检查 ACP UI 图片 MCP、等价 MCP、Agent 图片配置和系统 `imagegen`,再决定是否停止。
|
||||
3. 所有素材提取、修复、高清化、设计分析都必须把用户本地图片路径作为参考图传入,不能只用文字描述生成素材。
|
||||
4. 让图片 AI 输出透明 PNG 素材矩阵;由图片 AI 判断具体提取对象,只说明筛选规则:保留可复用且 HTML/CSS 难快速稳定还原的视觉素材,包括背景图、背景纹理或复杂背景层;排除纯文本、简单布局容器、普通 CSS 形状和整页截图。
|
||||
5. 临时素材矩阵放 `.local/screenshot-to-prototype/<slug>/`,再切到 `src/prototypes/<slug>/assets/`:
|
||||
|
||||
```bash
|
||||
node .agents/skills/screenshot-to-prototype/scripts/slice-asset-sheet.mjs \
|
||||
--input .local/screenshot-to-prototype/<slug>/asset-sheet.png \
|
||||
--output-dir src/prototypes/<slug>/assets \
|
||||
--grid 4x3 \
|
||||
--names icon-search,logo-brand,avatar-user,banner-hero \
|
||||
--manifest src/prototypes/<slug>/assets/asset-manifest.json
|
||||
```
|
||||
|
||||
6. 审计素材:
|
||||
|
||||
```bash
|
||||
node .agents/skills/screenshot-to-prototype/scripts/audit-assets.mjs \
|
||||
--manifest src/prototypes/<slug>/assets/asset-manifest.json
|
||||
```
|
||||
|
||||
7. 对模糊、污染、不透明、尺寸不足或误切素材,允许用原始本地源图作为参考图单独生成或修复;必要时附带问题素材。
|
||||
8. 页面用真实文本、React 结构、Grid/Flex、CSS variables、稳定 `aspect-ratio` 和响应式约束还原;不要把整张截图当背景。
|
||||
9. 交互状态、颜色继承、hover/focus 或复用性强的图标,可参考切图后重绘为 SVG 或使用合适图标组件。
|
||||
10. 运行 `node scripts/check-app-ready.mjs /prototypes/<slug>`,再用视觉回归工具检查真实运行截图。
|
||||
11. 最终回复提供轻量偏差报告,不新建长文档:
|
||||
- 展示或链接原图与真实运行截图。
|
||||
- 按 P0-P3 列出偏差,重点写未还原到位的问题,不写泛泛总结。
|
||||
- P0:阻塞验收或页面不可用;P1:关键布局/比例/内容明显不符;P2:素材风格、间距、图标、阴影等显著偏差;P3:细节优化。
|
||||
- 明确等待用户反馈选择是否继续修,不擅自进入下一轮大改。
|
||||
|
||||
## 命名
|
||||
|
||||
素材名用 kebab-case:`icon-*`、`logo-*`、`avatar-*`、`image-*`、`banner-*`、`cover-*`、`background-*`、`decoration-*`、`border-*`。含义不清时用 `asset-01`。
|
||||
|
||||
## 提示词
|
||||
|
||||
写图片生成提示词时再读 `references/prompts.md`。
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Screenshot To Prototype"
|
||||
short_description: "将明确提供的本地截图或设计稿还原为 client 可运行原型,并做真实截图回归"
|
||||
default_prompt: "使用 $screenshot-to-prototype 将这张截图或设计稿还原为当前 client 的可运行原型。"
|
||||
39
.agents/skills/screenshot-to-prototype/references/prompts.md
Normal file
39
.agents/skills/screenshot-to-prototype/references/prompts.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Screenshot To Prototype 提示词
|
||||
|
||||
通用规则:始终附带用户本地源图路径作为参考图;不要只靠文字生成。若工具不支持本地图片路径,停止。
|
||||
|
||||
## 素材矩阵
|
||||
|
||||
```text
|
||||
请基于参考截图,生成一张透明背景 PNG 素材矩阵。
|
||||
|
||||
由你判断具体提取对象。只按这些筛选规则:保留可复用且 HTML/CSS 难快速稳定还原的视觉素材,包括背景图、背景纹理或复杂背景层;排除纯文本、简单布局容器、普通 CSS 形状、整页截图和编号标签。
|
||||
|
||||
素材按清晰网格排列,保留透明留白,保持原视觉风格、颜色、阴影、透明度和比例。
|
||||
```
|
||||
|
||||
## 单素材修复
|
||||
|
||||
```text
|
||||
请基于参考截图修复这个单独 UI 素材,输出干净透明 PNG。
|
||||
|
||||
保持原形状、颜色、阴影和比例;去除背景污染和边缘脏点;补足透明留白;不要添加标签、外框或新装饰。
|
||||
```
|
||||
|
||||
## Banner/封面高清化
|
||||
|
||||
```text
|
||||
请基于参考截图生成这个 banner/封面素材的高清版本。
|
||||
|
||||
保持原构图、主体、色彩、风格和比例;只提升清晰度,不改变设计意图;除非原素材自带文字,否则不要新增文字。
|
||||
```
|
||||
|
||||
## 设计分析
|
||||
|
||||
```text
|
||||
请分析参考截图,输出用于 React/Vite 原型还原的简洁说明。
|
||||
|
||||
包含:源图尺寸、主要布局区块、间距节奏、近似色值、字体估计、素材用途与位置、desktop/tablet/mobile 响应式策略、建议改为 SVG 的元素。
|
||||
|
||||
不要生成独立主题。
|
||||
```
|
||||
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { findAlphaBounds, readPng } from './png-utils.mjs';
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const token = argv[index];
|
||||
if (!token.startsWith('--')) continue;
|
||||
const key = token.slice(2);
|
||||
const next = argv[index + 1];
|
||||
if (!next || next.startsWith('--')) args[key] = true;
|
||||
else {
|
||||
args[key] = next;
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (!args.manifest) {
|
||||
console.error('Usage: node scripts/audit-assets.mjs --manifest src/prototypes/<slug>/assets/asset-manifest.json');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const manifestPath = path.resolve(String(args.manifest));
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
const manifestDir = path.dirname(manifestPath);
|
||||
const results = [];
|
||||
|
||||
for (const asset of manifest.assets || []) {
|
||||
const file = String(asset.file || '');
|
||||
const assetPath = path.resolve(manifestDir, file);
|
||||
const issues = [];
|
||||
|
||||
if (!file || !fs.existsSync(assetPath)) {
|
||||
issues.push('missing-file');
|
||||
results.push({ id: asset.id || file, file, status: 'failed', issues });
|
||||
continue;
|
||||
}
|
||||
|
||||
const image = readPng(assetPath);
|
||||
const alphaBounds = findAlphaBounds(image);
|
||||
if (!image.hasAlphaChannel) issues.push('missing-alpha-channel');
|
||||
if (!alphaBounds) issues.push('empty-transparent-image');
|
||||
if (alphaBounds) {
|
||||
if (alphaBounds.x === 0 || alphaBounds.y === 0 || alphaBounds.x + alphaBounds.width === image.width || alphaBounds.y + alphaBounds.height === image.height) {
|
||||
issues.push('alpha-touches-edge');
|
||||
}
|
||||
const transparentCorners = [
|
||||
image.data[3],
|
||||
image.data[(image.width - 1) * 4 + 3],
|
||||
image.data[((image.height - 1) * image.width) * 4 + 3],
|
||||
image.data[((image.height * image.width) - 1) * 4 + 3],
|
||||
].filter((alpha) => alpha <= 8).length;
|
||||
if (transparentCorners < 3) issues.push('opaque-corners');
|
||||
}
|
||||
if (asset.width && Number(asset.width) !== image.width) issues.push('manifest-width-mismatch');
|
||||
if (asset.height && Number(asset.height) !== image.height) issues.push('manifest-height-mismatch');
|
||||
|
||||
results.push({
|
||||
id: asset.id || file,
|
||||
file,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
status: issues.length ? 'failed' : 'passed',
|
||||
issues,
|
||||
});
|
||||
}
|
||||
|
||||
const failed = results.filter((result) => result.status !== 'passed').length;
|
||||
const report = {
|
||||
status: failed ? 'failed' : 'passed',
|
||||
summary: {
|
||||
total: results.length,
|
||||
passed: results.length - failed,
|
||||
failed,
|
||||
},
|
||||
assets: results,
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
if (failed) process.exitCode = 1;
|
||||
}
|
||||
|
||||
main();
|
||||
182
.agents/skills/screenshot-to-prototype/scripts/png-utils.mjs
Normal file
182
.agents/skills/screenshot-to-prototype/scripts/png-utils.mjs
Normal file
@@ -0,0 +1,182 @@
|
||||
import fs from 'node:fs';
|
||||
import zlib from 'node:zlib';
|
||||
|
||||
const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
||||
|
||||
function crc32(buffer) {
|
||||
let crc = 0xffffffff;
|
||||
for (const byte of buffer) {
|
||||
crc ^= byte;
|
||||
for (let bit = 0; bit < 8; bit += 1) {
|
||||
crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1));
|
||||
}
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function chunk(type, data) {
|
||||
const typeBuffer = Buffer.from(type, 'ascii');
|
||||
const length = Buffer.alloc(4);
|
||||
length.writeUInt32BE(data.length, 0);
|
||||
const checksum = Buffer.alloc(4);
|
||||
checksum.writeUInt32BE(crc32(Buffer.concat([typeBuffer, data])), 0);
|
||||
return Buffer.concat([length, typeBuffer, data, checksum]);
|
||||
}
|
||||
|
||||
function paeth(a, b, c) {
|
||||
const p = a + b - c;
|
||||
const pa = Math.abs(p - a);
|
||||
const pb = Math.abs(p - b);
|
||||
const pc = Math.abs(p - c);
|
||||
if (pa <= pb && pa <= pc) return a;
|
||||
if (pb <= pc) return b;
|
||||
return c;
|
||||
}
|
||||
|
||||
export function readPng(filePath) {
|
||||
const buffer = fs.readFileSync(filePath);
|
||||
if (!buffer.subarray(0, 8).equals(PNG_SIGNATURE)) {
|
||||
throw new Error(`Unsupported PNG signature: ${filePath}`);
|
||||
}
|
||||
|
||||
let offset = 8;
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
let bitDepth = 0;
|
||||
let colorType = 0;
|
||||
const idatChunks = [];
|
||||
|
||||
while (offset < buffer.length) {
|
||||
const length = buffer.readUInt32BE(offset);
|
||||
const type = buffer.subarray(offset + 4, offset + 8).toString('ascii');
|
||||
const data = buffer.subarray(offset + 8, offset + 8 + length);
|
||||
offset += 12 + length;
|
||||
|
||||
if (type === 'IHDR') {
|
||||
width = data.readUInt32BE(0);
|
||||
height = data.readUInt32BE(4);
|
||||
bitDepth = data[8];
|
||||
colorType = data[9];
|
||||
} else if (type === 'IDAT') {
|
||||
idatChunks.push(data);
|
||||
} else if (type === 'IEND') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (bitDepth !== 8 || ![2, 6].includes(colorType)) {
|
||||
throw new Error(`Only 8-bit RGB/RGBA PNG files are supported: ${filePath}`);
|
||||
}
|
||||
|
||||
const channels = colorType === 6 ? 4 : 3;
|
||||
const bytesPerPixel = channels;
|
||||
const stride = width * channels;
|
||||
const inflated = zlib.inflateSync(Buffer.concat(idatChunks));
|
||||
const raw = Buffer.alloc(height * stride);
|
||||
let inputOffset = 0;
|
||||
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
const filter = inflated[inputOffset];
|
||||
inputOffset += 1;
|
||||
const rowOffset = y * stride;
|
||||
const prevRowOffset = (y - 1) * stride;
|
||||
|
||||
for (let x = 0; x < stride; x += 1) {
|
||||
const value = inflated[inputOffset + x];
|
||||
const left = x >= bytesPerPixel ? raw[rowOffset + x - bytesPerPixel] : 0;
|
||||
const up = y > 0 ? raw[prevRowOffset + x] : 0;
|
||||
const upLeft = y > 0 && x >= bytesPerPixel ? raw[prevRowOffset + x - bytesPerPixel] : 0;
|
||||
|
||||
if (filter === 0) raw[rowOffset + x] = value;
|
||||
else if (filter === 1) raw[rowOffset + x] = (value + left) & 255;
|
||||
else if (filter === 2) raw[rowOffset + x] = (value + up) & 255;
|
||||
else if (filter === 3) raw[rowOffset + x] = (value + Math.floor((left + up) / 2)) & 255;
|
||||
else if (filter === 4) raw[rowOffset + x] = (value + paeth(left, up, upLeft)) & 255;
|
||||
else throw new Error(`Unsupported PNG filter ${filter}: ${filePath}`);
|
||||
}
|
||||
inputOffset += stride;
|
||||
}
|
||||
|
||||
const rgba = Buffer.alloc(width * height * 4);
|
||||
for (let index = 0; index < width * height; index += 1) {
|
||||
const sourceOffset = index * channels;
|
||||
const targetOffset = index * 4;
|
||||
rgba[targetOffset] = raw[sourceOffset];
|
||||
rgba[targetOffset + 1] = raw[sourceOffset + 1];
|
||||
rgba[targetOffset + 2] = raw[sourceOffset + 2];
|
||||
rgba[targetOffset + 3] = colorType === 6 ? raw[sourceOffset + 3] : 255;
|
||||
}
|
||||
|
||||
return { width, height, data: rgba, hasAlphaChannel: colorType === 6 };
|
||||
}
|
||||
|
||||
export function writePng(filePath, image) {
|
||||
const { width, height, data } = image;
|
||||
const header = Buffer.alloc(13);
|
||||
header.writeUInt32BE(width, 0);
|
||||
header.writeUInt32BE(height, 4);
|
||||
header[8] = 8;
|
||||
header[9] = 6;
|
||||
header[10] = 0;
|
||||
header[11] = 0;
|
||||
header[12] = 0;
|
||||
|
||||
const scanlines = Buffer.alloc(height * (1 + width * 4));
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
const rowStart = y * (1 + width * 4);
|
||||
scanlines[rowStart] = 0;
|
||||
data.copy(scanlines, rowStart + 1, y * width * 4, (y + 1) * width * 4);
|
||||
}
|
||||
|
||||
fs.writeFileSync(filePath, Buffer.concat([
|
||||
PNG_SIGNATURE,
|
||||
chunk('IHDR', header),
|
||||
chunk('IDAT', zlib.deflateSync(scanlines)),
|
||||
chunk('IEND', Buffer.alloc(0)),
|
||||
]));
|
||||
}
|
||||
|
||||
export function cropPng(image, bbox) {
|
||||
const width = Math.max(0, bbox.width);
|
||||
const height = Math.max(0, bbox.height);
|
||||
const data = Buffer.alloc(width * height * 4);
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
const sourceStart = ((bbox.y + y) * image.width + bbox.x) * 4;
|
||||
const targetStart = y * width * 4;
|
||||
image.data.copy(data, targetStart, sourceStart, sourceStart + width * 4);
|
||||
}
|
||||
return { width, height, data, hasAlphaChannel: true };
|
||||
}
|
||||
|
||||
export function findAlphaBounds(image, bounds = { x: 0, y: 0, width: image.width, height: image.height }, alphaThreshold = 8) {
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -1;
|
||||
let maxY = -1;
|
||||
const startX = Math.max(0, bounds.x);
|
||||
const startY = Math.max(0, bounds.y);
|
||||
const endX = Math.min(image.width, bounds.x + bounds.width);
|
||||
const endY = Math.min(image.height, bounds.y + bounds.height);
|
||||
|
||||
for (let y = startY; y < endY; y += 1) {
|
||||
for (let x = startX; x < endX; x += 1) {
|
||||
if (image.data[(y * image.width + x) * 4 + 3] > alphaThreshold) {
|
||||
minX = Math.min(minX, x);
|
||||
minY = Math.min(minY, y);
|
||||
maxX = Math.max(maxX, x);
|
||||
maxY = Math.max(maxY, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (maxX < minX || maxY < minY) return null;
|
||||
return { x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1 };
|
||||
}
|
||||
|
||||
export function expandBounds(bounds, padding, image) {
|
||||
const x = Math.max(0, bounds.x - padding);
|
||||
const y = Math.max(0, bounds.y - padding);
|
||||
const right = Math.min(image.width, bounds.x + bounds.width + padding);
|
||||
const bottom = Math.min(image.height, bounds.y + bounds.height + padding);
|
||||
return { x, y, width: right - x, height: bottom - y };
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { cropPng, expandBounds, findAlphaBounds, readPng, writePng } from './png-utils.mjs';
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const token = argv[index];
|
||||
if (!token.startsWith('--')) continue;
|
||||
const key = token.slice(2);
|
||||
const next = argv[index + 1];
|
||||
if (!next || next.startsWith('--')) args[key] = true;
|
||||
else {
|
||||
args[key] = next;
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function usage() {
|
||||
return [
|
||||
'Usage:',
|
||||
' node scripts/slice-asset-sheet.mjs --input sheet.png --output-dir assets --grid 4x3 --names icon-a,banner-b --manifest assets/asset-manifest.json',
|
||||
'',
|
||||
'Options:',
|
||||
' --input Source transparent PNG sheet',
|
||||
' --output-dir Directory for extracted PNG assets',
|
||||
' --grid Grid size as COLSxROWS',
|
||||
' --names Optional comma-separated asset names',
|
||||
' --manifest Output manifest path',
|
||||
' --padding Transparent padding to keep around alpha bounds, default 1',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function toKebabName(input, fallback) {
|
||||
const normalized = String(input || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/gu, '-')
|
||||
.replace(/^-+|-+$/gu, '');
|
||||
return normalized || fallback;
|
||||
}
|
||||
|
||||
function parseGrid(grid) {
|
||||
const match = String(grid || '').match(/^(\d+)x(\d+)$/iu);
|
||||
if (!match) throw new Error('--grid must use COLSxROWS, for example 4x3');
|
||||
const columns = Number(match[1]);
|
||||
const rows = Number(match[2]);
|
||||
if (!Number.isInteger(columns) || !Number.isInteger(rows) || columns < 1 || rows < 1) {
|
||||
throw new Error('--grid values must be positive integers');
|
||||
}
|
||||
return { columns, rows };
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (!args.input || !args['output-dir'] || !args.grid) {
|
||||
console.error(usage());
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const inputPath = path.resolve(String(args.input));
|
||||
const outputDir = path.resolve(String(args['output-dir']));
|
||||
const manifestPath = path.resolve(String(args.manifest || path.join(outputDir, 'asset-manifest.json')));
|
||||
const padding = Math.max(0, Number(args.padding ?? 1));
|
||||
const { columns, rows } = parseGrid(args.grid);
|
||||
const names = String(args.names || '').split(',').map((item) => item.trim()).filter(Boolean);
|
||||
const image = readPng(inputPath);
|
||||
const cellWidth = Math.floor(image.width / columns);
|
||||
const cellHeight = Math.floor(image.height / rows);
|
||||
|
||||
if (cellWidth < 1 || cellHeight < 1) {
|
||||
throw new Error('Grid creates empty cells; use fewer columns or rows');
|
||||
}
|
||||
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
const assets = [];
|
||||
let assetIndex = 0;
|
||||
|
||||
for (let row = 0; row < rows; row += 1) {
|
||||
for (let column = 0; column < columns; column += 1) {
|
||||
const cell = {
|
||||
x: column * cellWidth,
|
||||
y: row * cellHeight,
|
||||
width: column === columns - 1 ? image.width - column * cellWidth : cellWidth,
|
||||
height: row === rows - 1 ? image.height - row * cellHeight : cellHeight,
|
||||
};
|
||||
const alphaBounds = findAlphaBounds(image, cell);
|
||||
if (!alphaBounds) continue;
|
||||
|
||||
const paddedBounds = expandBounds(alphaBounds, padding, image);
|
||||
const id = toKebabName(names[assetIndex], `asset-${String(assetIndex + 1).padStart(2, '0')}`);
|
||||
const file = `${id}.png`;
|
||||
writePng(path.join(outputDir, file), cropPng(image, paddedBounds));
|
||||
|
||||
assets.push({
|
||||
id,
|
||||
file,
|
||||
width: paddedBounds.width,
|
||||
height: paddedBounds.height,
|
||||
sourceCell: { column, row },
|
||||
sourceBounds: paddedBounds,
|
||||
alphaBounds,
|
||||
});
|
||||
assetIndex += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const manifest = {
|
||||
schemaVersion: 1,
|
||||
source: path.relative(outputDir, inputPath) || path.basename(inputPath),
|
||||
grid: { columns, rows },
|
||||
assets,
|
||||
};
|
||||
fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
|
||||
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
console.log(JSON.stringify({ status: 'ok', manifest: manifestPath, assets: assets.length }, null, 2));
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user