81fff0eda5
新增画板生图背景色自动决策模块,并接入角色、图标和 UI 素材提取三条路径。 扩展用户可选背景色到 11 个,并把 auto 作为默认背景色选项。 保留 anime-seg 内部解析能力,但用户界面只展示并提交默认 birefnet。 BgFilter 失败时增加本地纯色背景去背兜底,降低外部服务失败影响。 新增 BGFilter 探活与 gpt-image-2 尺寸生成调试脚本。 同步更新编辑器、生图链路和后端架构相关文档。
265 lines
7.7 KiB
JavaScript
265 lines
7.7 KiB
JavaScript
import { deflateSync } from 'node:zlib';
|
|
import { existsSync, readFileSync } from 'node:fs';
|
|
import { dirname, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const root = resolve(__dirname, '..');
|
|
|
|
const DEFAULT_BASE_URL = 'http://58.87.105.82/bgfilter';
|
|
const DEFAULT_SCREEN_COLOR = '#CFEFFF';
|
|
const DEFAULT_SEG_MODEL = 'birefnet';
|
|
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
|
|
function loadEnvFile(path) {
|
|
if (!existsSync(path)) {
|
|
return {};
|
|
}
|
|
const env = {};
|
|
const content = readFileSync(path, 'utf8');
|
|
for (const line of content.split(/\r?\n/)) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed || trimmed.startsWith('#')) {
|
|
continue;
|
|
}
|
|
const eqIndex = trimmed.indexOf('=');
|
|
if (eqIndex < 0) {
|
|
continue;
|
|
}
|
|
const key = trimmed.slice(0, eqIndex).trim();
|
|
let value = trimmed.slice(eqIndex + 1).trim();
|
|
if (
|
|
(value.startsWith('"') && value.endsWith('"')) ||
|
|
(value.startsWith("'") && value.endsWith("'"))
|
|
) {
|
|
value = value.slice(1, -1);
|
|
}
|
|
env[key] = value;
|
|
}
|
|
return env;
|
|
}
|
|
|
|
const fileEnv = {
|
|
...loadEnvFile(resolve(root, '.env')),
|
|
...loadEnvFile(resolve(root, '.env.local')),
|
|
...loadEnvFile(resolve(root, '.env.secrets.local')),
|
|
};
|
|
|
|
function readConfig(name) {
|
|
return process.env[name] || fileEnv[name] || '';
|
|
}
|
|
|
|
function readArg(name) {
|
|
const prefix = `--${name}=`;
|
|
const value = process.argv.find((arg) => arg.startsWith(prefix));
|
|
return value ? value.slice(prefix.length) : '';
|
|
}
|
|
|
|
function readPositiveIntArg(name, fallback) {
|
|
const rawValue = readArg(name);
|
|
if (!rawValue) {
|
|
return fallback;
|
|
}
|
|
const value = Number.parseInt(rawValue, 10);
|
|
if (!Number.isFinite(value) || value <= 0) {
|
|
throw new Error(`--${name} 必须是正整数,当前为 ${rawValue}`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function normalizeHexColor(value) {
|
|
const color = value.trim();
|
|
if (!/^#[0-9a-fA-F]{6}$/.test(color)) {
|
|
throw new Error(`screen_color 必须是 #RRGGBB,当前为 ${value}`);
|
|
}
|
|
return color.toUpperCase();
|
|
}
|
|
|
|
function parseHexColor(value) {
|
|
const color = normalizeHexColor(value);
|
|
return [
|
|
Number.parseInt(color.slice(1, 3), 16),
|
|
Number.parseInt(color.slice(3, 5), 16),
|
|
Number.parseInt(color.slice(5, 7), 16),
|
|
];
|
|
}
|
|
|
|
function crc32(buffer) {
|
|
let crc = 0xffffffff;
|
|
for (const byte of buffer) {
|
|
crc ^= byte;
|
|
for (let bit = 0; bit < 8; bit += 1) {
|
|
crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
|
|
}
|
|
}
|
|
return (crc ^ 0xffffffff) >>> 0;
|
|
}
|
|
|
|
function pngChunk(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 makeProbePng(screenColor, width, height) {
|
|
const [red, green, blue] = parseHexColor(screenColor);
|
|
const rowSize = 1 + width * 4;
|
|
const raw = Buffer.alloc(rowSize * height);
|
|
const subjectLeft = Math.floor(width * 0.31);
|
|
const subjectRight = Math.ceil(width * 0.69);
|
|
const subjectTop = Math.floor(height * 0.25);
|
|
const subjectBottom = Math.ceil(height * 0.75);
|
|
|
|
for (let y = 0; y < height; y += 1) {
|
|
const row = y * rowSize;
|
|
raw[row] = 0;
|
|
for (let x = 0; x < width; x += 1) {
|
|
const offset = row + 1 + x * 4;
|
|
const inSubject =
|
|
x >= subjectLeft && x < subjectRight && y >= subjectTop && y < subjectBottom;
|
|
raw[offset] = inSubject ? 35 : red;
|
|
raw[offset + 1] = inSubject ? 35 : green;
|
|
raw[offset + 2] = inSubject ? 35 : blue;
|
|
raw[offset + 3] = 255;
|
|
}
|
|
}
|
|
|
|
const ihdr = Buffer.alloc(13);
|
|
ihdr.writeUInt32BE(width, 0);
|
|
ihdr.writeUInt32BE(height, 4);
|
|
ihdr[8] = 8;
|
|
ihdr[9] = 6;
|
|
ihdr[10] = 0;
|
|
ihdr[11] = 0;
|
|
ihdr[12] = 0;
|
|
|
|
return Buffer.concat([
|
|
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
pngChunk('IHDR', ihdr),
|
|
pngChunk('IDAT', deflateSync(raw)),
|
|
pngChunk('IEND', Buffer.alloc(0)),
|
|
]);
|
|
}
|
|
|
|
function buildEndpoint(baseUrl) {
|
|
const base = baseUrl.trim().replace(/\/+$/, '');
|
|
if (!base) {
|
|
throw new Error('BgFilter base url 为空');
|
|
}
|
|
return `${base}/remove-background`;
|
|
}
|
|
|
|
const baseUrl =
|
|
readArg('base-url') ||
|
|
readConfig('GENARRATIVE_EDITOR_BGFILTER_BASE_URL') ||
|
|
DEFAULT_BASE_URL;
|
|
const screenColor = normalizeHexColor(
|
|
readArg('screen-color') ||
|
|
readConfig('GENARRATIVE_EDITOR_BGFILTER_SCREEN_COLOR') ||
|
|
DEFAULT_SCREEN_COLOR,
|
|
);
|
|
const segModel =
|
|
readArg('seg-model') ||
|
|
readConfig('GENARRATIVE_EDITOR_BGFILTER_SEG_MODEL') ||
|
|
DEFAULT_SEG_MODEL;
|
|
const token =
|
|
readConfig('GENARRATIVE_EDITOR_BGFILTER_TOKEN') ||
|
|
readConfig('GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN');
|
|
const timeoutMs = Number.parseInt(
|
|
readArg('timeout-ms') ||
|
|
readConfig('GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS') ||
|
|
`${DEFAULT_TIMEOUT_MS}`,
|
|
10,
|
|
);
|
|
const width = readPositiveIntArg('width', 96);
|
|
const height = readPositiveIntArg('height', width);
|
|
|
|
const endpoint = buildEndpoint(baseUrl);
|
|
const inputImage = makeProbePng(screenColor, width, height);
|
|
const form = new FormData();
|
|
form.append(
|
|
'file',
|
|
new Blob([inputImage], { type: 'image/png' }),
|
|
'bgfilter-live-probe.png',
|
|
);
|
|
form.append('screen_color', screenColor);
|
|
form.append('seg_model', segModel);
|
|
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
const startedAt = Date.now();
|
|
|
|
console.log('BgFilter 探活');
|
|
console.log(`目标: ${endpoint}`);
|
|
console.log(`screen_color: ${screenColor}`);
|
|
console.log(`seg_model: ${segModel}`);
|
|
console.log(`image: ${width}x${height}, ${inputImage.length} bytes`);
|
|
console.log(`timeout_ms: ${timeoutMs}`);
|
|
console.log(`token: ${token ? '已配置' : '未配置'}\n`);
|
|
|
|
try {
|
|
const headers = {};
|
|
if (token) {
|
|
headers['X-Genarrative-Image-Token'] = token;
|
|
}
|
|
const response = await fetch(endpoint, {
|
|
method: 'POST',
|
|
headers,
|
|
body: form,
|
|
signal: controller.signal,
|
|
});
|
|
clearTimeout(timer);
|
|
|
|
const elapsedMs = Date.now() - startedAt;
|
|
const contentType = response.headers.get('content-type') || '';
|
|
const upstreamElapsedMs = response.headers.get('x-bgfilter-elapsed-ms') || '';
|
|
const upstreamSegModel = response.headers.get('x-bgfilter-seg-model') || '';
|
|
const upstreamScreenColor = response.headers.get('x-bgfilter-screen-color') || '';
|
|
const body = Buffer.from(await response.arrayBuffer());
|
|
|
|
if (!response.ok) {
|
|
console.error(`失败: HTTP ${response.status} ${response.statusText}`);
|
|
console.error(`耗时: ${elapsedMs}ms`);
|
|
console.error(body.toString('utf8').slice(0, 500));
|
|
process.exit(1);
|
|
}
|
|
|
|
if (!contentType.toLowerCase().startsWith('image/')) {
|
|
console.error(`失败: 返回 content-type 不是图片: ${contentType || '(empty)'}`);
|
|
console.error(body.toString('utf8').slice(0, 500));
|
|
process.exit(1);
|
|
}
|
|
|
|
if (body.length === 0) {
|
|
console.error('失败: 返回图片为空');
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log(`成功: HTTP ${response.status} ${response.statusText}`);
|
|
console.log(`耗时: ${elapsedMs}ms`);
|
|
console.log(`content-type: ${contentType}`);
|
|
console.log(`bytes: ${body.length}`);
|
|
if (upstreamElapsedMs) {
|
|
console.log(`x-bgfilter-elapsed-ms: ${upstreamElapsedMs}`);
|
|
}
|
|
if (upstreamSegModel) {
|
|
console.log(`x-bgfilter-seg-model: ${upstreamSegModel}`);
|
|
}
|
|
if (upstreamScreenColor) {
|
|
console.log(`x-bgfilter-screen-color: ${upstreamScreenColor}`);
|
|
}
|
|
} catch (error) {
|
|
clearTimeout(timer);
|
|
const elapsedMs = Date.now() - startedAt;
|
|
const message =
|
|
error?.name === 'AbortError'
|
|
? `请求超时 (${timeoutMs}ms)`
|
|
: error?.message || String(error);
|
|
console.error(`失败: ${message}`);
|
|
console.error(`耗时: ${elapsedMs}ms`);
|
|
process.exit(1);
|
|
}
|