Files
Genarrative/scripts/make-taonier-hand-spirit-transparent.mjs
kdletters 29e84a77d1
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 5m31s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 5m31s
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 5m47s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 5m48s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m43s
Project CI / AI game creator shell Rust crates (push) Successful in 2m3s
Project CI / Repository checks (push) Successful in 3m43s
Project CI / Frontend tests (push) Successful in 4m53s
Project CI / Native shell tests (push) Successful in 6m35s
Project CI / Backend tests (push) Successful in 7m25s
Project CI / AI game creator shell web tests (push) Successful in 2m35s
切换 Tiantoken 并删除旧 Vidu 音效
新增 TIANTOKEN_BASE_URL / TIANTOKEN_API_KEY 配置并切换文本与图片链路

保留 Suno VectorEngine 路径,删除旧 Vidu submit/poll/builder 实现

同步脚本、文档、测试与运行时 provider 路由
2026-09-15 19:43:35 +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.TIANTOKEN_BASE_URL || '')
.trim()
.replace(/\/+$/u, ''),
apiKey: String(loaded.TIANTOKEN_API_KEY || '').trim(),
timeoutMs: Number.parseInt(
String(loaded.TIANTOKEN_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(`Tiantoken ${response.status}: ${text.slice(0, 600)}`);
}
return JSON.parse(text);
} catch (error) {
if (error?.name === 'AbortError') {
throw new Error(`Tiantoken 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 TIANTOKEN_BASE_URL or TIANTOKEN_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('Tiantoken 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),
);