Files
Genarrative/scripts/make-taonier-hand-spirit-transparent.mjs
T
kdletters 071faa482c 统一 Rust 与 TypeScript 格式化门禁
纳入 AGC Cargo workspace 的统一 rustfmt 检查与格式化入口

完成项目 TypeScript/Prettier 与 Rust 全量格式化

修复 Pingora expected executable 门禁的空白敏感误报

同步开发运维文档与 AGC skill pack 格式化忽略规则
2026-09-01 16:28:34 +08:00

328 lines
8.7 KiB
JavaScript

import { Blob, Buffer } from 'node:buffer';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const repoRoot = path.resolve(__dirname, '..');
const timeoutMsDefault = 180000;
const sourcePath = path.join(
repoRoot,
'public',
'branding',
'taonier-logo-hand-spirit-concepts',
'taonier-hand-spirit-01-gentle-hand-spirit.png',
);
const outputDir = path.dirname(sourcePath);
const chromaPath = path.join(
outputDir,
'taonier-hand-spirit-01-gentle-hand-spirit-transparent-source.png',
);
const manifestPath = path.join(
outputDir,
'taonier-hand-spirit-01-gentle-hand-spirit-transparent-manifest.json',
);
const args = new Map();
for (let index = 2; index < process.argv.length; index += 1) {
const raw = process.argv[index];
if (!raw.startsWith('--')) {
continue;
}
const next = process.argv[index + 1];
if (next && !next.startsWith('--')) {
args.set(raw, next);
index += 1;
} else {
args.set(raw, true);
}
}
const prompt = [
'Use the uploaded image as the exact edit target.',
'Preserve the logo subject exactly: same abstract hand shape, same clay spirit shape, same proportions, same placement, same scale, same colors, same soft vector style.',
'Do not redesign, simplify, recolor, crop, rotate, add details, remove highlights, change the hand, or change the clay spirit.',
'Replace only the white/off-white background with a perfectly flat solid #00ff00 chroma-key background.',
'The background must be one uniform #00ff00 color with no shadows, gradients, texture, reflections, floor plane, border, or lighting variation.',
'Do not use #00ff00 anywhere inside the logo subject.',
'No text, no watermark, no UI, no extra marks, no border.',
'Keep crisp clean edges and generous safe area so the result can be converted into a transparent PNG.',
].join('\n');
function readDotenv(fileName) {
const filePath = path.join(repoRoot, fileName);
if (!existsSync(filePath)) {
return {};
}
const values = {};
for (const line of readFileSync(filePath, 'utf8').split(/\r?\n/u)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) {
continue;
}
const match = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u.exec(trimmed);
if (!match) {
continue;
}
let value = match[2].trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
values[match[1]] = value;
}
return values;
}
function resolveEnv() {
const loaded = {
...readDotenv('.env.example'),
...readDotenv('.env.local'),
...readDotenv('.env.secrets.local'),
...process.env,
};
return {
baseUrl: String(loaded.VECTOR_ENGINE_BASE_URL || '')
.trim()
.replace(/\/+$/u, ''),
apiKey: String(loaded.VECTOR_ENGINE_API_KEY || '').trim(),
timeoutMs: Number.parseInt(
String(loaded.VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS || timeoutMsDefault),
10,
),
};
}
function buildEditUrl(baseUrl) {
return baseUrl.endsWith('/v1')
? `${baseUrl}/images/edits`
: `${baseUrl}/v1/images/edits`;
}
function buildDryRunFields() {
return {
model: 'gpt-image-2',
prompt,
n: '1',
size: '1024x1024',
image: sourcePath,
};
}
function collectStringsByKey(value, targetKey, output) {
if (Array.isArray(value)) {
value.forEach((entry) => collectStringsByKey(entry, targetKey, output));
return;
}
if (!value || typeof value !== 'object') {
return;
}
for (const [key, nested] of Object.entries(value)) {
if (key === targetKey) {
if (typeof nested === 'string' && nested.trim()) {
output.push(nested.trim());
}
if (Array.isArray(nested)) {
nested.forEach((entry) => {
if (typeof entry === 'string' && entry.trim()) {
output.push(entry.trim());
}
});
}
}
collectStringsByKey(nested, targetKey, output);
}
}
function extractImageUrls(payload) {
const urls = [];
collectStringsByKey(payload, 'url', urls);
collectStringsByKey(payload, 'image', urls);
collectStringsByKey(payload, 'image_url', urls);
return [...new Set(urls)].filter((url) => /^https?:\/\//u.test(url));
}
function extractBase64Images(payload) {
const values = [];
collectStringsByKey(payload, 'b64_json', values);
return values;
}
function inferExtensionFromBytes(bytes) {
if (bytes.subarray(0, 8).equals(Buffer.from('\x89PNG\r\n\x1A\n', 'binary'))) {
return 'png';
}
if (bytes.subarray(0, 3).equals(Buffer.from([0xff, 0xd8, 0xff]))) {
return 'jpg';
}
if (
bytes.subarray(0, 4).toString('ascii') === 'RIFF' &&
bytes.subarray(8, 12).toString('ascii') === 'WEBP'
) {
return 'webp';
}
return 'png';
}
async function fetchJson(url, options, timeoutMs) {
const abortController = new AbortController();
const timer = setTimeout(() => abortController.abort(), timeoutMs);
try {
const response = await fetch(url, {
...options,
signal: abortController.signal,
});
const text = await response.text();
if (!response.ok) {
throw new Error(`VectorEngine ${response.status}: ${text.slice(0, 600)}`);
}
return JSON.parse(text);
} catch (error) {
if (error?.name === 'AbortError') {
throw new Error(`VectorEngine request timed out after ${timeoutMs}ms`);
}
throw error;
} finally {
clearTimeout(timer);
}
}
async function downloadUrl(url, timeoutMs) {
const abortController = new AbortController();
const timer = setTimeout(() => abortController.abort(), timeoutMs);
try {
const response = await fetch(url, { signal: abortController.signal });
if (!response.ok) {
throw new Error(`download ${response.status}`);
}
return Buffer.from(await response.arrayBuffer());
} catch (error) {
if (error?.name === 'AbortError') {
throw new Error(
`Generated image download timed out after ${timeoutMs}ms`,
);
}
throw error;
} finally {
clearTimeout(timer);
}
}
function createEditFormData() {
const form = new FormData();
const imageBytes = readFileSync(sourcePath);
form.append('model', 'gpt-image-2');
form.append('prompt', prompt);
form.append('n', '1');
form.append('size', '1024x1024');
form.append(
'image',
new Blob([imageBytes], { type: 'image/png' }),
path.basename(sourcePath),
);
return form;
}
async function generateChromaSource() {
const env = resolveEnv();
if (!env.baseUrl || !env.apiKey) {
throw new Error(
JSON.stringify({
ok: false,
error: 'Missing VECTOR_ENGINE_BASE_URL or VECTOR_ENGINE_API_KEY',
hasBaseUrl: Boolean(env.baseUrl),
hasApiKey: Boolean(env.apiKey),
}),
);
}
const payload = await fetchJson(
buildEditUrl(env.baseUrl),
{
method: 'POST',
headers: {
Authorization: `Bearer ${env.apiKey}`,
Accept: 'application/json',
},
body: createEditFormData(),
},
env.timeoutMs,
);
const urls = extractImageUrls(payload);
const b64Images = extractBase64Images(payload);
let bytes;
if (urls[0]) {
bytes = await downloadUrl(urls[0], env.timeoutMs);
} else if (b64Images[0]) {
bytes = Buffer.from(b64Images[0], 'base64');
} else {
throw new Error('VectorEngine returned no image');
}
mkdirSync(outputDir, { recursive: true });
writeFileSync(chromaPath, bytes);
const extension = inferExtensionFromBytes(bytes);
writeFileSync(
manifestPath,
`${JSON.stringify(
{
model: 'gpt-image-2',
endpoint: '/v1/images/edits',
size: '1024x1024',
source: path.relative(repoRoot, sourcePath),
chromaSource: path.relative(repoRoot, chromaPath),
finalOutput:
'public/branding/taonier-logo-hand-spirit-concepts/taonier-hand-spirit-01-gentle-hand-spirit-transparent.png',
generatedAt: new Date().toISOString(),
prompt,
},
null,
2,
)}\n`,
'utf8',
);
return { chromaPath, manifestPath, extension, bytes: bytes.length };
}
const dryRun = args.has('--dry-run') || !args.has('--live');
if (dryRun) {
console.log(
JSON.stringify(
{
mode: 'dry-run',
sourcePath,
chromaPath,
manifestPath,
fields: buildDryRunFields(),
},
null,
2,
),
);
process.exit(0);
}
if (!existsSync(sourcePath)) {
console.error(
JSON.stringify({
ok: false,
error: 'Source image does not exist',
sourcePath,
}),
);
process.exit(1);
}
console.log(
JSON.stringify({ ok: true, ...(await generateChromaSource()) }, null, 2),
);