481 lines
26 KiB
JavaScript
481 lines
26 KiB
JavaScript
import { Buffer } from 'node:buffer';
|
||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||
import path from 'node:path';
|
||
|
||
const repoRoot = process.cwd();
|
||
const outputDir = path.join(
|
||
repoRoot,
|
||
'public',
|
||
'branding',
|
||
'taonier-logo-concepts',
|
||
);
|
||
const defaultTimeoutMs = 420000;
|
||
|
||
const dimensionalConcepts = [
|
||
{
|
||
id: 'taonier-clay-spark',
|
||
title: '灵感陶团',
|
||
prompt:
|
||
'为中文产品“陶泥儿”设计一个无文字 Logo 图标。产品是精品 AI UGC 创作与轻休闲小游戏平台,核心理念是把脑洞、梗和小游戏像陶泥一样捏出来。图标主体是一团被轻轻捏塑的温润陶泥,内部自然形成一枚发光灵感火花和少量 AI 节点点线,整体高级、亲切、年轻、有传播感。使用暖陶土色、奶白、薄荷绿、深墨色少量点缀,居中构图,适合作为 App icon 和品牌主标。禁止文字、字母、汉字、水印、按钮、界面元素、复杂背景、儿童黏土课风格。',
|
||
},
|
||
{
|
||
id: 'taonier-play-mold',
|
||
title: '开玩模具',
|
||
prompt:
|
||
'为中文产品“陶泥儿”设计一个无文字 Logo 图标。产品强调 AI 创作、UGC、自制小游戏、玩梗传播和轻度休闲。图标主体是一枚柔软陶泥捏成的圆角播放符号,播放三角像被手指压出的模具凹槽,周围有两三颗精品感小星点和像素级小方块,表达“捏个脑洞,马上开玩”。风格是现代品牌标志,柔软但不幼稚,干净、可缩小识别。禁止文字、字母、汉字、水印、真实陶艺工具、UI 按钮、教程感。',
|
||
},
|
||
{
|
||
id: 'taonier-meme-bubble',
|
||
title: '造梗气泡',
|
||
prompt:
|
||
'为中文产品“陶泥儿”设计一个无文字 Logo 图标。产品是 AI UGC 创作社区,主打精品内容、梗传播、裂变分享、休闲小游戏。图标用一团软陶泥变形成聊天气泡和小表情的组合,气泡边缘像被揉捏过,中心有抽象笑脸和创意火花,但不要做儿童玩具感。品牌气质年轻、松弛、聪明、有社交传播力。配色使用陶土橙、奶白、清爽蓝绿和少量深色轮廓。禁止文字、字母、汉字、水印、复杂场景、表情包文字。',
|
||
},
|
||
{
|
||
id: 'taonier-creation-loop',
|
||
title: '共创回路',
|
||
prompt:
|
||
'为中文产品“陶泥儿”设计一个无文字 Logo 图标。产品理念是 AI 与用户共同把灵感塑形成可玩的 UGC 作品。图标主体由两条柔软陶泥带构成循环造物轨迹,形成一个抽象无限符号和手工捏塑旋涡,中间嵌入一颗小型游戏棋子或星点,表达共创、迭代、传播和精品打磨。风格简洁高级、几何清楚、移动端小尺寸仍可识别。禁止文字、字母、汉字、水印、复杂阴影、科技冷硬金属感。',
|
||
},
|
||
{
|
||
id: 'taonier-premium-seal',
|
||
title: '精品泥印',
|
||
prompt:
|
||
'为中文产品“陶泥儿”设计一个无文字 Logo 图标。产品主打精品 AI 创作、UGC 作品和轻小游戏发布。图标是一个被压印过的软陶徽章,外形像圆润印章但更现代,中间有抽象火花、小游戏方块和一处捏痕,表达“精品内容由脑洞塑形”。整体要有品牌信任感和高级手作质感,不要像儿童陶艺班。使用暖陶土、奶油白、莓红或湖蓝少量点缀,清晰居中。禁止文字、字母、汉字、水印、传统篆刻字、真实照片。',
|
||
},
|
||
];
|
||
|
||
const flatConcepts = [
|
||
{
|
||
id: 'taonier-flat-play-clay',
|
||
title: '扁平开捏',
|
||
prompt:
|
||
'为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。产品是 AI UGC 创作与轻休闲小游戏平台,主张“把脑洞捏成小游戏”。图标只使用一个柔软圆润的陶泥形主轮廓,内部用极简负形播放三角表达“马上开玩”,整体像现代 App icon 的核心符号。风格要求:flat vector logo, clean geometric, friendly, mainstream, memorable, high contrast, scalable, minimal shapes, solid colors, subtle 2D shadow only。配色使用暖陶土橙、奶油白、清爽薄荷绿或深墨色,最多 3 个主色。禁止:3D、立体、拟物、厚重阴影、渐变高光、照片质感、复杂纹理、中文字、英文字母、水印、UI 按钮、复杂背景、吉祥物。',
|
||
},
|
||
{
|
||
id: 'taonier-flat-spark-clay',
|
||
title: '灵感泥星',
|
||
prompt:
|
||
'为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。产品强调 AI 创作、UGC、造梗、精品轻小游戏。图形主体是一枚圆润陶泥团,中心用简洁四角星或火花负形表达灵感和 AI 生成,外轮廓要一眼像“可塑形的软泥”,但必须保持现代、主流、亲和、有记忆点。风格要求:flat vector brand mark, simple silhouette, app icon ready, no realism, no texture, no 3D, crisp edges, 2D friendly illustration。最多 3 色,暖陶土 + 奶油白 + 少量蓝绿。禁止文字、字母、水印、复杂小节点、儿童手工课风格。',
|
||
},
|
||
{
|
||
id: 'taonier-flat-meme-smile',
|
||
title: '造梗笑泥',
|
||
prompt:
|
||
'为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。产品主打 UGC、玩梗传播、裂变分享和轻休闲小游戏。图形是一团被捏成圆润聊天气泡的陶泥,内部只保留极简笑脸或一颗小星点,表达“造梗”和“分享快乐”。整体要像主流社交娱乐 App 的 Logo,亲和、轻松、容易记住,小尺寸清楚。风格要求:flat vector logo, simple, bold, friendly, clean, no gradients, no 3D, no mascot complexity。配色不超过 3 色。禁止中文字、英文字母、水印、表情包文字、复杂装饰、立体高光。',
|
||
},
|
||
{
|
||
id: 'taonier-flat-loop-mold',
|
||
title: '共创泥环',
|
||
prompt:
|
||
'为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。产品理念是用户与 AI 共同把灵感塑形成可玩的 UGC 作品。图形用一条柔软陶泥带形成简洁闭环或抽象无限符号,中间留出小星点负形,表达共创、迭代、传播和精品打磨。视觉要主流、简洁、亲和,不要科技冷硬。风格要求:flat vector symbol, clean loop mark, minimal, memorable, scalable, solid colors, crisp silhouette, suitable for app icon。禁止 3D、拟物、厚阴影、复杂渐变、文字、字母、水印。',
|
||
},
|
||
{
|
||
id: 'taonier-flat-seal-blocks',
|
||
title: '精品泥印',
|
||
prompt:
|
||
'为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。产品强调精品 AI 作品、UGC 创作和小游戏发布。图形是一枚现代软陶印记,外形为圆角徽章或圆润印章,内部用 2 到 3 个简洁小方块和一颗星点表达“作品”“小游戏”“精品内容”。整体应像可长期使用的品牌主标,主流、干净、亲和、有辨识度。风格要求:flat vector logo, bold simple shapes, app icon ready, minimal color palette, no realism, no texture。禁止文字、字母、水印、传统篆刻、3D、复杂阴影、拟物陶艺。',
|
||
},
|
||
];
|
||
|
||
const v3Concepts = [
|
||
{
|
||
id: 'taonier-v3-finger-spark',
|
||
title: '灵感捏痕',
|
||
prompt:
|
||
'为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。不要使用播放三角、聊天气泡、笑脸、循环无限符号、褐色陶土主色、碎片小元素。产品是 AI UGC 创作与轻休闲小游戏平台,核心是“把脑洞捏成可玩的作品”。图形主体是一个醒目的圆润软形,内部只有一枚极简指纹捏痕与小火花负形,表达“被手指一捏,灵感成型”。风格:主流 App icon、flat vector、bold simple silhouette、friendly、memorable、high contrast、可缩小识别。配色:珊瑚橙或莓红作为主色,奶油白负形,少量青绿色投影或边缘点缀,最多 3 色。画面居中,留白干净。禁止文字、字母、水印、3D、拟物、厚阴影、渐变高光、照片质感、复杂纹理、表情包感、UI 按钮。',
|
||
},
|
||
{
|
||
id: 'taonier-v3-seed-pop',
|
||
title: '脑洞种子',
|
||
prompt:
|
||
'为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。不要使用播放三角、聊天气泡、笑脸、无限循环、传统印章、褐色主色或多碎元素。产品主打 AI 创作、UGC、梗传播、精品轻小游戏。图标主体是一颗圆润明亮的“脑洞种子”:像软泥被捏成的一颗种子/小芽,顶部有一个简洁星点缺口,表达灵感生长、内容生成、人人创作。风格:flat vector logo, simple, mainstream, warm, lively, app icon ready, strong outline, minimal shapes。配色使用高饱和青绿、珊瑚粉、奶油白、深墨色中的 2-3 色,不要大面积褐色。禁止文字、字母、水印、3D、拟物、照片、复杂渐变、表情、儿童黏土课风格。',
|
||
},
|
||
{
|
||
id: 'taonier-v3-magic-dot',
|
||
title: '一捏成型',
|
||
prompt:
|
||
'为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。避开播放按钮、聊天气泡、笑脸、循环符号、褐色陶土和堆叠小图标。产品理念是用户轻轻一捏,AI 把脑洞生成小游戏和 UGC 作品。图形由两个圆润手捏触点和中间一个闪光成型点组成,像“捏合灵感”的瞬间,但不要画真实手指。整体应非常简洁,有强记忆点,像主流创作娱乐 App 的标志。风格:flat vector, iconic, minimal, friendly, bold shape, clear at 32px。配色:亮紫红或珊瑚红主色,奶油白负形,青绿色小面积辅助。禁止文字、字母、水印、3D、厚阴影、渐变高光、复杂纹理。',
|
||
},
|
||
{
|
||
id: 'taonier-v3-work-gem',
|
||
title: '作品胶囊',
|
||
prompt:
|
||
'为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。不要使用播放三角、聊天气泡、笑脸、循环无限符号、褐色主色、多枚小卡片或碎图标。产品强调精品 AI UGC 作品和轻小游戏创作。图形主体是一枚被捏成圆角宝石/胶囊的抽象作品符号,内部只有一条柔软弧线切面和一个小星点,表达“脑洞被打磨成精品”。风格:flat vector logo, premium but friendly, simple, memorable, app icon, solid colors, no texture。配色:湖蓝或青绿主色,珊瑚橙点缀,奶白负形,深墨小轮廓可选。禁止文字、字母、水印、3D、复杂渐变、照片质感、游戏手柄、图片卡片、用户头像。',
|
||
},
|
||
{
|
||
id: 'taonier-v3-soft-t',
|
||
title: '软体 T 形',
|
||
prompt:
|
||
'为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。尝试做一个抽象但亲和的品牌首字母符号,灵感来自 Taonier / 陶泥儿 的 T 和“被捏塑的软泥”。不要出现真实字母 T 的硬直排版,而是用一笔圆润软形构成可记忆的图腾。必须避开播放三角、聊天气泡、笑脸、循环符号、褐色陶土主色和碎元素。风格:flat vector brand mark, modern, friendly, bold, iconic, simple silhouette, app icon ready。配色:明亮珊瑚红、奶油白、薄荷青或深墨,最多 3 色。禁止文字、英文字母直出、汉字、水印、3D、拟物、厚阴影、复杂纹理。',
|
||
},
|
||
];
|
||
|
||
const magicDotConcepts = [
|
||
{
|
||
id: 'taonier-magic-dot-orbit',
|
||
title: '捏合星核',
|
||
prompt:
|
||
'围绕“陶泥儿”V3 方案“一捏成型”做 Logo 延展。设计一个无文字扁平矢量主标:两个圆润软泥触点从左右轻轻合拢,中心不是碰撞爆炸,而是一颗稳定的星核/作品核,外形要形成完整、可记忆的品牌符号。必须避免播放三角、聊天气泡、笑脸、循环无限符号、褐色陶土、真实手指、括号感、爆炸特效和碎元素。风格:flat vector logo, iconic, minimal, friendly, mainstream app icon, strong silhouette, clear at 32px。配色:珊瑚红或莓红主形,奶油白负形,青绿色只做中心小面积,最多 3 色。无文字、无字母、无水印、无 3D、无厚阴影、无拟物。',
|
||
},
|
||
{
|
||
id: 'taonier-magic-dot-seal',
|
||
title: '成型印记',
|
||
prompt:
|
||
'围绕“陶泥儿”V3 方案“一捏成型”做 Logo 延展。设计一个无文字扁平矢量主标:图形像一枚被两侧轻轻按压成型的软形印记,中心留出一个简洁星点或小圆孔,表达 AI 把脑洞塑形成作品。整体要比原本左右括号更完整,外轮廓形成一个独特图腾。禁止播放按钮、聊天气泡、笑脸、循环符号、褐色陶土主色、多小图标、真实手、爆炸火花。风格:flat vector, bold simple shape, friendly premium, memorable, app icon ready, solid colors。配色:亮珊瑚、奶油白、薄荷青或深墨,最多 3 色。',
|
||
},
|
||
{
|
||
id: 'taonier-magic-dot-squish',
|
||
title: '软泥合拍',
|
||
prompt:
|
||
'围绕“陶泥儿”V3 方案“一捏成型”做 Logo 延展。设计一个无文字扁平矢量 Logo:两个软泥形不是分散的括号,而是上下错位地挤压出中心灵感点,像“啪嗒一捏,作品成型”的瞬间。图形需要亲和、轻松、年轻,但不做表情包。必须保持元素极少,只有两块主形和一个中心成型点。禁止播放三角、聊天气泡、笑脸、无限循环、褐色主色、复杂渐变、拟物质感、真实手指、文字、字母。风格:flat vector brand mark, simple, memorable, high contrast, scalable。配色:莓红、奶白、青绿或明黄点缀。',
|
||
},
|
||
{
|
||
id: 'taonier-magic-dot-mold',
|
||
title: '灵感模口',
|
||
prompt:
|
||
'围绕“陶泥儿”V3 方案“一捏成型”做 Logo 延展。设计一个无文字扁平矢量主标:外形像一个被捏开的柔软模口,中心浮出一颗极简星点,表达从软泥模口里生成作品。它应该是一眼可记住的抽象符号,不像聊天框、不像播放键、不像括号。风格:flat vector logo, modern, friendly, clean, bold, minimal, app icon。配色使用高识别珊瑚红或玫粉主色,奶油白负形,少量青绿点缀。禁止褐色陶土、真实陶艺、3D、高光、厚阴影、复杂小碎片、文字、水印。',
|
||
},
|
||
{
|
||
id: 'taonier-magic-dot-bloom',
|
||
title: '捏开灵感',
|
||
prompt:
|
||
'围绕“陶泥儿”V3 方案“一捏成型”做 Logo 延展。设计一个无文字扁平矢量 Logo:用两片圆润软形夹出中央一颗灵感点,整体像一个正在打开的创意容器,但不要像花朵、聊天气泡或笑脸。图形要完整、主流、亲和、醒目,适合 App icon 和品牌主标。禁止播放三角、聊天气泡、笑脸、循环符号、褐色陶土、碎元素、真实手、复杂花瓣。风格:flat vector, minimal brand mark, strong silhouette, warm, youthful, memorable。配色:珊瑚红、奶油白、青绿,最多 3 色。',
|
||
},
|
||
];
|
||
|
||
const handsConcepts = [
|
||
{
|
||
id: 'taonier-hands-cradle-spark',
|
||
title: '托住灵感',
|
||
prompt:
|
||
'围绕“陶泥儿”Logo 方向 03 的“上下两只手托住灵感”的感觉继续打磨。设计一个无文字扁平矢量主标:上下两片圆润软掌状形体像手但不要画真实手指,轻轻托住中央一颗简洁灵感星核,表达用户与 AI 一起把脑洞捏成作品。整体要完整、主流、亲和、醒目,适合 App icon。避免播放三角、聊天气泡、笑脸、眼睛、花朵、循环符号、褐色陶土、多碎元素和真实手掌插画。风格:flat vector logo, bold simple silhouette, friendly, memorable, premium but warm, clear at 32px。配色:上方珊瑚红、下方青绿色、中央奶油白或金色小星,最多 3 色。无文字、无字母、无水印、无 3D、无厚阴影。',
|
||
},
|
||
{
|
||
id: 'taonier-hands-pinched-gem',
|
||
title: '合捏成珠',
|
||
prompt:
|
||
'围绕“陶泥儿”Logo 方向 03 的“上下两只手”感觉做延展。设计一个无文字扁平矢量主标:上下一对抽象软手 / 软泥掌从两侧微微合捏,中间形成一颗小圆珠或作品核。图形要像品牌符号,不像手势教学图;保留托举与成型的温柔感。禁止播放三角、聊天气泡、笑脸、眼睛、花朵、褐色主色、真实手指、复杂掌纹、碎小图标。风格:flat vector, minimal, mainstream app logo, high contrast, iconic, friendly。配色:莓红、奶白、薄荷青、少量深墨,最多 3 色。',
|
||
},
|
||
{
|
||
id: 'taonier-hands-soft-bowl',
|
||
title: '创意托碗',
|
||
prompt:
|
||
'围绕“陶泥儿”Logo 方向 03 的上下手感做主标延展。设计一个无文字扁平矢量 Logo:下方是一片像手掌也像软泥托碗的圆润形体,上方是一片较小软形轻轻压合,中间浮出星点,表达“轻托脑洞、AI 捏成作品”。整体要简洁、有包容感、年轻亲和。避免像眼睛、嘴巴、聊天气泡、播放器、花朵、真实手掌、儿童黏土课。风格:flat vector logo, bold simple shape, app icon ready, clean, memorable。配色:青绿主托、珊瑚红上形、奶白中心,最多 3 色。',
|
||
},
|
||
{
|
||
id: 'taonier-hands-formed-seal',
|
||
title: '双掌泥印',
|
||
prompt:
|
||
'围绕“陶泥儿”Logo 方向 03 的上下两只手感觉做更完整的图腾。设计一个无文字扁平矢量主标:两片抽象软掌上下扣合,外轮廓形成一个圆润印记,中心保留一个星形负空间,像“被双手捏出的创意印记”。要有主流品牌感,不要像宗教手势、医疗关怀、儿童手工。禁止播放三角、聊天气泡、笑脸、眼睛、花朵、循环符号、褐色陶土、真实手指、复杂纹理。风格:flat vector, iconic, simple, friendly premium, solid colors, scalable。配色:珊瑚红、奶油白、青绿或深墨,最多 3 色。',
|
||
},
|
||
{
|
||
id: 'taonier-hands-pop-capsule',
|
||
title: '掌心开捏',
|
||
prompt:
|
||
'围绕“陶泥儿”Logo 方向 03 的“上下两只手托住灵感”感觉做更活泼版本。设计一个无文字扁平矢量 Logo:上下两片软掌像打开的胶囊,中央小星点从掌心弹出,表达“脑洞被捏出来”。图形需要有传播感、亲和力、记忆点,但不要像表情包或聊天软件。禁止播放三角、聊天气泡、笑脸、眼睛、花朵、褐色陶土、真实手指、碎元素。风格:flat vector brand mark, simple, bold, youthful, app icon, high contrast。配色:亮珊瑚红、薄荷青、奶白,最多 3 色。',
|
||
},
|
||
];
|
||
|
||
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 style = String(args.get('--style') || 'dimensional').trim();
|
||
const concepts =
|
||
style === 'flat'
|
||
? flatConcepts
|
||
: style === 'v3'
|
||
? v3Concepts
|
||
: style === 'magic'
|
||
? magicDotConcepts
|
||
: style === 'hands'
|
||
? handsConcepts
|
||
: dimensionalConcepts;
|
||
const selectedOutputDir =
|
||
style === 'flat'
|
||
? path.join(repoRoot, 'public', 'branding', 'taonier-logo-flat-concepts')
|
||
: style === 'v3'
|
||
? path.join(repoRoot, 'public', 'branding', 'taonier-logo-v3-concepts')
|
||
: style === 'magic'
|
||
? path.join(
|
||
repoRoot,
|
||
'public',
|
||
'branding',
|
||
'taonier-logo-magic-dot-concepts',
|
||
)
|
||
: style === 'hands'
|
||
? path.join(
|
||
repoRoot,
|
||
'public',
|
||
'branding',
|
||
'taonier-logo-hands-concepts',
|
||
)
|
||
: outputDir;
|
||
|
||
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 || defaultTimeoutMs),
|
||
10,
|
||
),
|
||
};
|
||
}
|
||
|
||
function buildUrl(baseUrl) {
|
||
return baseUrl.endsWith('/v1')
|
||
? `${baseUrl}/images/generations`
|
||
: `${baseUrl}/v1/images/generations`;
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
|
||
async function generateConcept(env, concept) {
|
||
const requestBody = {
|
||
model: 'gpt-image-2-all',
|
||
prompt: concept.prompt,
|
||
n: 1,
|
||
size: '1024x1024',
|
||
};
|
||
const payload = await fetchJson(
|
||
buildUrl(env.baseUrl),
|
||
{
|
||
method: 'POST',
|
||
headers: {
|
||
Authorization: `Bearer ${env.apiKey}`,
|
||
Accept: 'application/json',
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify(requestBody),
|
||
},
|
||
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 for ${concept.id}`);
|
||
}
|
||
|
||
mkdirSync(selectedOutputDir, { recursive: true });
|
||
const extension = inferExtensionFromBytes(bytes);
|
||
const outputPath = path.join(selectedOutputDir, `${concept.id}.${extension}`);
|
||
writeFileSync(outputPath, bytes);
|
||
return outputPath;
|
||
}
|
||
|
||
const dryRun = args.has('--dry-run') || !args.has('--live');
|
||
const onlyIds = String(args.get('--only') || '')
|
||
.split(',')
|
||
.map((value) => value.trim())
|
||
.filter(Boolean);
|
||
const limit = Number.parseInt(String(args.get('--limit') || '0'), 10);
|
||
const selected = concepts
|
||
.filter((concept) => !onlyIds.length || onlyIds.includes(concept.id))
|
||
.slice(0, limit > 0 ? limit : concepts.length);
|
||
|
||
if (dryRun) {
|
||
console.log(
|
||
JSON.stringify(
|
||
{
|
||
mode: 'dry-run',
|
||
style,
|
||
outputDir: selectedOutputDir,
|
||
count: selected.length,
|
||
requests: selected.map((concept) => ({
|
||
id: concept.id,
|
||
title: concept.title,
|
||
body: {
|
||
model: 'gpt-image-2-all',
|
||
prompt: concept.prompt,
|
||
n: 1,
|
||
size: '1024x1024',
|
||
},
|
||
})),
|
||
},
|
||
null,
|
||
2,
|
||
),
|
||
);
|
||
process.exit(0);
|
||
}
|
||
|
||
const env = resolveEnv();
|
||
if (!env.baseUrl || !env.apiKey) {
|
||
console.error(
|
||
JSON.stringify({
|
||
ok: false,
|
||
error: 'Missing VECTOR_ENGINE_BASE_URL or VECTOR_ENGINE_API_KEY',
|
||
hasBaseUrl: Boolean(env.baseUrl),
|
||
hasApiKey: Boolean(env.apiKey),
|
||
}),
|
||
);
|
||
process.exit(1);
|
||
}
|
||
|
||
const generated = [];
|
||
for (const concept of selected) {
|
||
console.log(`Generating ${concept.id}...`);
|
||
generated.push(await generateConcept(env, concept));
|
||
}
|
||
|
||
console.log(
|
||
JSON.stringify(
|
||
{
|
||
ok: true,
|
||
count: generated.length,
|
||
files: generated,
|
||
},
|
||
null,
|
||
2,
|
||
),
|
||
);
|