初始化项目版本

This commit is contained in:
Axhub Make
2026-07-29 16:04:39 +08:00
commit 4305a1082b
2629 changed files with 760590 additions and 0 deletions

View 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`

View File

@@ -0,0 +1,4 @@
interface:
display_name: "Screenshot To Prototype"
short_description: "将明确提供的本地截图或设计稿还原为 client 可运行原型,并做真实截图回归"
default_prompt: "使用 $screenshot-to-prototype 将这张截图或设计稿还原为当前 client 的可运行原型。"

View 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 的元素。
不要生成独立主题。
```

View File

@@ -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();

View 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 };
}

View File

@@ -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();