feat: add zodiac fantasy series generator

This commit is contained in:
2026-05-31 11:39:03 +08:00
parent 68d4580a98
commit d184f7fe6d
3 changed files with 792 additions and 0 deletions

View File

@@ -9,6 +9,7 @@
## 部署事实
- 平台:个人 VPS `76.13.31.179`Docker Compose接入现有 Coolify Traefik
- 发布状态VPS 生产已发布,仅个人使用
- 最近生产数据同步2026-05-31`有你家族 · 生肖幻装系列` session `s_zodiac_fantasy_20260531_main` 已同步到 VPS `data/`,包含 12 张专业投影六视图专利图、18 张系列/单款宣发图、6 份专业文字资产;产品尺度统一按 50cm+ 具身 AI 智能陪伴机器人处理,视频暂缓未生成。本轮使用 `scripts/generate-zodiac-fantasy-series-assets.mjs` 以桌面参考图 `1400a0c9-6501-4a8f-942a-59d5e82edacd.png` 为视觉锚点生成。
- 最近生产部署2026-05-31`有你家族 · 亥猪` 模板约束已发布并完成生产:视频任务统一改为 45 秒;图片包模板移除默认动物鼻子、尾巴、耳朵等提示,改为 40cm+ AI 陪伴机器人摆件、正面宽约 28cm、侧面深约 22cm、软壳/短绒触感但不改变基础机甲结构。生产 session `s_mpsn5ef3_edc352` 已完成 64 张基础图片、4 张十二生肖装甲组合图、5 条 45 秒亥猪视频和 2 条 30 秒十二生肖集合视频。
- 上一轮生产部署2026-05-30视频 provider 改为默认 Seedance`VIDEO_PROVIDER=seedance`OpenAI Sora 仅作为可选回退;实测 Ark / Seedance `doubao-seedance-2-0` R2V 不接受 `duration=60`,当前 Seedance 单任务按 15 秒提交。若仍需 60 秒成片,需要分段拼接,或回退 OpenAI Sora 的延展链路。
- 最近生产数据同步2026-05-30`有你家族 · 糯糯猪` session `s_mps3u047_48e383` 已同步到 VPS `data/`,包含专利包、配件包、生产打样包、宣发包共 64 张图片Seedance 生产环境 Key 已换新并作为当前默认视频 provider。

View File

@@ -12,6 +12,7 @@
"docker:logs": "docker compose logs -f web",
"resources:index": "node scripts/build-resource-index.mjs data",
"styles:previews": "node scripts/generate-style-previews.mjs",
"images:zodiac-fantasy": "node scripts/generate-zodiac-fantasy-series-assets.mjs",
"videos:seedance": "node scripts/seedance-60s-compose.mjs",
"videos:seedance60": "node scripts/seedance-60s-compose.mjs --target-seconds 60"
},

View File

@@ -0,0 +1,790 @@
#!/usr/bin/env node
import { readFileSync } from 'node:fs';
import fs from 'node:fs/promises';
import path from 'node:path';
import { randomBytes } from 'node:crypto';
import { execFile as execFileCb, spawn } from 'node:child_process';
import { promisify } from 'node:util';
import { fileURLToPath } from 'node:url';
const execFile = promisify(execFileCb);
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const args = parseArgs(process.argv.slice(2));
const sourcePath = path.resolve(args.source || '/Users/kangwan/Desktop/1400a0c9-6501-4a8f-942a-59d5e82edacd.png');
const env = {
...readEnvFile(path.join(root, args.env || '.env.local')),
...process.env,
};
const apiKey = env.OPENAI_API_KEY;
const apiBase = env.GPT_API_BASE || 'https://api.openai.com/v1';
const model = env.GPT_IMAGE_EDIT_MODEL || env.GPT_IMAGE_MODEL || 'gpt-image-2';
const sessionId = args.session || `s_zodiac_fantasy_${new Date().toISOString().slice(0, 10).replaceAll('-', '')}_${randomHex(3)}`;
const sessionPath = path.join(root, 'data', 'sessions', `${sessionId}.json`);
const uploadDir = path.join(root, 'data', 'uploads');
const packDir = path.join(root, 'data', 'packs');
const exportDir = path.join(root, 'data', 'exports');
const artifactDir = path.join(root, 'artifacts', 'screenshots', 'zodiac-fantasy-series', sessionId);
const prepareOnly = Boolean(args['prepare-only']);
const phase = args.phase || 'all';
const limit = args.limit ? Number(args.limit) : Infinity;
const only = String(args.only || '').split(',').map(item => item.trim()).filter(Boolean);
const fresh = Boolean(args.fresh);
const VARIANTS = [
{
key: 'rat',
zodiac: '子鼠',
sku: 'Rat',
color: '雾蓝 / 银白 / 冰青 visor',
crop: { x: 40, y: 130, w: 300, h: 300 },
traits: '圆形鼠耳幻装、雾蓝短绒外套、蓝灰斜挎带、冰青情绪 visor、圆润安全的侧面模块',
},
{
key: 'ox',
zodiac: '丑牛',
sku: 'Ox',
color: '象牙白 / 奶咖 / 暖金 visor',
crop: { x: 380, y: 130, w: 300, h: 300 },
traits: '柔性牛角幻装、奶咖短绒、棕色能量肩带、暖金 visor、稳重可靠的低重心体态',
},
{
key: 'tiger',
zodiac: '寅虎',
sku: 'Tiger',
color: '琥珀橙 / 黑纹 / 暖橙 visor',
crop: { x: 720, y: 130, w: 300, h: 300 },
traits: '虎纹短绒头罩、圆形耳部幻装、琥珀橙功能色、黑色肩带、精神但不攻击的守护者气质',
},
{
key: 'rabbit',
zodiac: '卯兔',
sku: 'Rabbit',
color: '薰衣草紫 / 月白 / 粉紫 visor',
crop: { x: 1060, y: 130, w: 330, h: 300 },
traits: '柔软兔耳幻装、淡紫短绒、蝴蝶结局部点缀、粉紫 visor、女性向温柔陪伴气质',
},
{
key: 'dragon',
zodiac: '辰龙',
sku: 'Dragon',
color: '青瓷绿 / 珠白 / 青蓝 visor',
crop: { x: 40, y: 425, w: 320, h: 310 },
traits: '东方龙鬃幻装、云纹柔性模块、青瓷绿短绒、深绿肩带、青蓝 visor、灵动但圆润安全',
},
{
key: 'snake',
zodiac: '巳蛇',
sku: 'Snake',
color: '薄荷绿 / 奶白 / 青蓝 visor',
crop: { x: 380, y: 425, w: 310, h: 310 },
traits: '鳞片纹短绒头罩、柔和蛇形纹样、薄荷绿外壳、深绿肩带、青蓝 visor、安静聪明的陪伴感',
},
{
key: 'horse',
zodiac: '午马',
sku: 'Horse',
color: '珊瑚粉 / 赤金 / 粉橙 visor',
crop: { x: 720, y: 425, w: 310, h: 310 },
traits: '马鬃幻装冠盖、珊瑚粉短绒、赤金马蹄形胸扣、粉橙 visor、活力轻快但保持机器人结构',
},
{
key: 'goat',
zodiac: '未羊',
sku: 'Goat',
color: '奶白 / 青灰 / 青蓝 visor',
crop: { x: 1060, y: 425, w: 330, h: 310 },
traits: '卷曲羊角柔性幻装、羊毛纹短绒、奶白体块、青灰肩带、青蓝 visor、柔软治愈的家庭陪伴气质',
},
{
key: 'monkey',
zodiac: '申猴',
sku: 'Monkey',
color: '燕麦米 / 深咖 / 暖金 visor',
crop: { x: 40, y: 735, w: 310, h: 310 },
traits: '圆耳猴系幻装、燕麦短绒、咖色肩带、暖金 visor、聪明俏皮但不过度卡通',
},
{
key: 'rooster',
zodiac: '酉鸡',
sku: 'Rooster',
color: '奶白 / 珊瑚红 / 红粉 visor',
crop: { x: 380, y: 735, w: 310, h: 310 },
traits: '柔性鸡冠幻装、小翅状侧面装饰、珊瑚红肩带、红粉 visor、明快醒目的系列识别',
},
{
key: 'dog',
zodiac: '戌狗',
sku: 'Dog',
color: '浅蓝 / 奶白 / 冰蓝 visor',
crop: { x: 720, y: 735, w: 310, h: 310 },
traits: '垂耳狗系幻装、浅蓝短绒、爪印冠盖纹样、蓝色肩带、冰蓝 visor、忠诚可靠的智能伙伴感',
},
{
key: 'pig',
zodiac: '亥猪',
sku: 'Pig',
color: '樱粉 / 奶白 / 粉紫 visor',
crop: { x: 1060, y: 735, w: 330, h: 310 },
traits: '粉色猪系幻装、柔软耳部外轮廓、鼻形胸扣而非真实突出猪鼻、粉紫 visor、主角型温暖陪伴感',
},
];
const MARKETING_ASSETS = [
{
templateId: 'zodiac_fantasy_collection_kv',
view: 'collection-kv',
title: '生肖幻装系列主视觉',
description: '12 款 50cm+ 智能陪伴机器人集合主视觉,面向宣发和招商展示。',
prompt: 'premium hero key visual of all twelve variants, clean studio, Hai Pig as one of the twelve, no video',
},
{
templateId: 'zodiac_fantasy_retail_grid',
view: 'retail-grid',
title: '十二款零售陈列图',
description: '用于电商首屏、招商页和产品目录的 12 款整列展示。',
prompt: 'retail catalog grid, twelve 50cm companion robots, unified scale and spacing',
},
{
templateId: 'zodiac_fantasy_material_board',
view: 'material-board',
title: '毛绒软壳材质宣发板',
description: '短绒、软壳、visor、肩带、徽章和扣件的材质卖点展示。',
prompt: 'material close-up board, plush textile, soft shell, emotional visor, strap buckle, M badge',
},
{
templateId: 'zodiac_fantasy_female_lifestyle',
view: 'lifestyle',
title: '女性向家庭空间场景',
description: '面向女性用户和家庭陪伴场景的空间化宣发图。',
prompt: 'lifestyle scene for female users, home space, one 50cm companion robot displayed at real scale',
},
{
templateId: 'zodiac_fantasy_sku_system',
view: 'sku-system',
title: '十二生肖 SKU 色彩系统',
description: '十二款配色、纹样和幻装模块的系列化设计板。',
prompt: 'SKU color system board, twelve palette swatches and front silhouettes, professional design system',
},
{
templateId: 'zodiac_fantasy_patent_overview',
view: 'patent-overview',
title: '系列专利申报总览',
description: '12 款外观专利申报时可用的系列总览图。',
prompt: 'design patent overview board, twelve front views at same scale, simple dimensions, white background',
},
];
const SKU_MARKETING_ASSETS = VARIANTS.map(variant => ({
key: variant.key,
zodiac: variant.zodiac,
templateId: `zodiac_fantasy_${variant.key}_sku_card`,
view: `${variant.key}-sku-card`,
title: `${variant.zodiac} · 单款 SKU 宣发卡`,
description: `${variant.zodiac}款 50cm+ 生肖幻装 AI 陪伴机器人单品宣发图。`,
prompt: `single product SKU hero card for ${variant.zodiac}, 50cm+ real product scale, premium catalog composition`,
variant,
}));
await fs.mkdir(uploadDir, { recursive: true });
await fs.mkdir(packDir, { recursive: true });
await fs.mkdir(exportDir, { recursive: true });
await fs.mkdir(path.dirname(sessionPath), { recursive: true });
await fs.mkdir(artifactDir, { recursive: true });
await assertFile(sourcePath);
const sourceFilename = `${sessionId}_source_zodiac_fantasy.png`;
const sourceUploadPath = path.join(uploadDir, sourceFilename);
await fs.copyFile(sourcePath, sourceUploadPath);
const cropDir = path.join(uploadDir, `${sessionId}_zodiac_crops`);
await fs.mkdir(cropDir, { recursive: true });
await makeVariantCrops(sourcePath, cropDir);
const contactSheet = path.join(artifactDir, 'zodiac-crops-contact-sheet.jpg');
await makeContactSheet(cropDir, contactSheet);
let session = await loadOrCreateSession();
syncTextAssets(session);
await saveSession(session);
if (prepareOnly) {
await writeTextExport(session);
console.log(JSON.stringify({
sessionId,
source: `/api/img/uploads/${sourceFilename}`,
cropDir,
contactSheet,
sessionPath,
prepareOnly: true,
}, null, 2));
process.exit(0);
}
if (!apiKey) fail('OPENAI_API_KEY missing');
const generated = [];
if (phase === 'all' || phase === 'patent') {
const patentPack = ensurePack(session, 'patent', '专利六视图包');
const pending = filterItems(VARIANTS, variant => ({
templateId: `patent_${variant.key}_six_view`,
pack: patentPack,
})).slice(0, limit);
for (const variant of pending) {
const templateId = `patent_${variant.key}_six_view`;
if (hasAsset(patentPack, templateId) && !fresh) {
generated.push({ templateId, status: 'skipped' });
continue;
}
const imagePath = path.join(cropDir, `${variant.key}.png`);
const dataUrl = await generateEdit({
imagePath,
prompt: patentPrompt(variant),
size: args.size || '1536x1024',
});
const asset = await writeAsset({
pack: patentPack,
templateId,
kind: 'patent',
view: `${variant.key}-six-view`,
title: `${variant.zodiac} · 专业投影六视图`,
description: `${variant.zodiac}款 50cm+ 智能陪伴机器人幻装外观,正/背/左/右/俯/仰六视图。`,
prompt: patentPrompt(variant),
anchorImageUrl: `/api/img/uploads/${sessionId}_zodiac_crops/${variant.key}.png`,
dataUrl,
meta: { zodiac: variant.zodiac, sku: variant.sku, color: variant.color, generatedBy: 'generate-zodiac-fantasy-series-assets' },
});
upsertAsset(patentPack, asset);
generated.push({ templateId, status: 'generated', url: asset.url });
await saveSession(session);
}
}
if (phase === 'all' || phase === 'marketing') {
const marketingPack = ensurePack(session, 'marketing', '系列宣发包');
const marketingItems = [...MARKETING_ASSETS, ...SKU_MARKETING_ASSETS];
const pending = filterItems(marketingItems, spec => ({
templateId: spec.templateId,
pack: marketingPack,
})).slice(0, limit);
for (const spec of pending) {
if (hasAsset(marketingPack, spec.templateId) && !fresh) {
generated.push({ templateId: spec.templateId, status: 'skipped' });
continue;
}
const dataUrl = await generateEdit({
imagePath: spec.variant ? path.join(cropDir, `${spec.variant.key}.png`) : sourceUploadPath,
prompt: marketingPrompt(spec),
size: args.size || '1536x1024',
});
const asset = await writeAsset({
pack: marketingPack,
templateId: spec.templateId,
kind: 'marketing',
view: spec.view,
title: spec.title,
description: spec.description,
prompt: marketingPrompt(spec),
anchorImageUrl: spec.variant ? `/api/img/uploads/${sessionId}_zodiac_crops/${spec.variant.key}.png` : `/api/img/uploads/${sourceFilename}`,
dataUrl,
meta: { generatedBy: 'generate-zodiac-fantasy-series-assets', zodiac: spec.variant?.zodiac, color: spec.variant?.color },
});
upsertAsset(marketingPack, asset);
generated.push({ templateId: spec.templateId, status: 'generated', url: asset.url });
await saveSession(session);
}
}
syncTextAssets(session);
await saveSession(session);
await writeTextExport(session);
await writeSeriesManifest(session);
console.log(JSON.stringify({
sessionId,
generated,
gallery: `/api/gallery/${sessionId}`,
textExport: `/api/export/${sessionId}_text_assets.json`,
manifest: `/api/export/${sessionId}_series_manifest.json`,
cropContactSheet: contactSheet,
}, null, 2));
function patentPrompt(variant) {
return [
`Create a professional design-patent orthographic projection sheet for ONE product variant: 有你家族 · 生肖幻装系列 · ${variant.zodiac}.`,
`Use the reference crop as the exact visual anchor. Preserve the variant identity: ${variant.traits}. Preserve its palette: ${variant.color}.`,
'Hard product scale constraint: this is a 50cm+ embodied AI companion robot for home spaces, NOT a small blind-box toy, NOT a keychain, NOT a desktop mini figure. Body volume, stable feet, and low center of gravity must feel like a real 50cm+ consumer robot.',
'Show exactly six orthographic views of the same product at the same scale: front view, back view, left side view, right side view, top view, bottom view. Arrange them in a clean 3 by 2 technical board. Use strict orthographic projection, no perspective distortion, no dramatic camera angle.',
'Fixed shared base must remain across every view: large rounded helmet head, short body, rounded limbs, black/dark graphite face base, curved emotional visor screen, round side modules, circular M family badge on chest, diagonal energy shoulder strap, central buckle, short stable legs and dark sole pads.',
'Material expression: plush fantasy outfit over a soft-shell robot body; short-pile textile and soft matte shell, safe rounded edges, female-friendly, warm technology, premium product design.',
'Patent clarity requirements: white or very light gray background, thin gray projection guide lines, clear silhouette, no props, no humans, no packaging, no scene, no accessories beside the robot, no shadows that hide contours.',
'Mandatory dimension marker: leave clear left margin in the FRONT view and draw a clean vertical dimension line labeled "约50cm+" or "50cm+" beside the robot. This is a hard acceptance requirement; an image without the height ruler fails. Do not omit, crop out, or hide this height marker. Keep width/depth believable for a 50cm+ robot.',
'Text rule: use only concise technical labels such as 主视图/FRONT, 后视图/BACK, 左视图/LEFT, 右视图/RIGHT, 顶视图/TOP, 底视图/BOTTOM, 约50cm+. No random text, no watermark, no price, no logo other than the product M badge visible on the robot.',
].join('\n');
}
function marketingPrompt(spec) {
if (spec.variant) {
return [
`Create a premium single-product marketing SKU card for 有你家族 · 生肖幻装系列 · ${spec.variant.zodiac}.`,
`Use the reference crop as the exact visual anchor. Preserve the character identity: ${spec.variant.traits}. Preserve palette: ${spec.variant.color}.`,
'Hard scale rule: this is a 50cm+ embodied AI companion robot for home spaces, not a small toy, keychain, blind box, or mini figurine. Make the body volume and product presence credible.',
'Preserve the shared family design language: rounded AI companion robot base, curved visor emotion screen, M badge, diagonal shoulder strap, side circular modules, short stable limbs, plush fantasy outfit over a soft-shell robot body.',
'Visual composition: one hero front or three-quarter front product render, one small orthographic side/back inset if helpful, soft studio lighting, clean premium catalog layout, subtle 50cm+ dimension cue, female-friendly warm technology feeling.',
'No humans, no celebrities, no third-party IP, no price tags, no watermark, no weapons. Avoid excessive text; if text appears, keep it concise and professional.',
`Visual task: ${spec.prompt}.`,
].join('\n');
}
return [
'Create a professional marketing visual for 有你家族 · 生肖幻装系列.',
'Reference image defines the exact 12 variants. Preserve the shared family design language: rounded AI companion robot base, curved visor emotion screen, M badge, diagonal shoulder strap, side circular modules, 50cm+ real product scale, plush fantasy zodiac outfits.',
'This series is for female users and family spaces: premium, warm technology, soft plush texture, clean modern lifestyle, not childish, not combat mech, not tiny blind-box toy.',
'Hard scale rule: all robots are 50cm+ intelligent companion robots. Make the body volume and environmental scale credible; never make them appear as small figurines.',
'Keep all twelve zodiac variants recognizable: 子鼠 blue mouse, 丑牛 ivory ox, 寅虎 amber tiger, 卯兔 lavender rabbit, 辰龙 mint dragon, 巳蛇 green snake, 午马 coral horse, 未羊 cream goat, 申猴 oatmeal monkey, 酉鸡 coral rooster, 戌狗 blue dog, 亥猪 pink pig.',
`Visual task: ${spec.prompt}.`,
'Use clean premium lighting and real product rendering. Avoid excessive text. No third-party IP, no celebrities, no watermarks, no price tags, no weapons.',
].join('\n');
}
function syncTextAssets(sessionValue) {
const now = Date.now();
const assets = [
{
templateId: 'series_positioning_brief',
kind: 'project',
title: '系列定位说明',
description: '生肖幻装系列的产品定位、用户对象和设计边界。',
outputFormat: 'paragraph',
content: `“有你家族 · 生肖幻装系列”定位为 50cm+ 具身 AI 智能陪伴机器人系列。十二生肖不是小挂件、盲盒或静态公仔,而是同一机器人家族基础结构上的十二套毛绒幻装外观。产品面向女性用户、家庭空间、礼赠场景和陪伴型智能硬件市场,核心气质为温暖科技、柔软可亲、系列收藏感和可落地的实体产品感。所有款式都必须保留情绪 visor、M 家族徽章、斜挎能量肩带、圆润低重心机身和 50cm+ 尺寸感。`,
},
{
templateId: 'design_system_rule',
kind: 'project',
title: '外观系统设计规则',
description: '固定资产、可变幻装区和十二款统一规则。',
outputFormat: 'bullets',
content: [
'固定核心:大头短身、圆润站立比例、情绪显示 visor、深色面部底层、胸前 M 徽章、斜挎肩带、中央功能扣、圆形侧面模块、短手短脚、稳定脚底。',
'可变区域:头部毛绒幻装、耳/角/鬃/冠等生肖识别件、肩带配色、胸扣造型、手臂纹样、腿脚边缘色、短绒表面纹理。',
'尺寸规则:每款高度按 50cm+ 产品处理,六视图和宣发图都要有真实体量,不允许表现成 10-20cm 小玩具。',
'材质规则:外部可采用短绒毛绒、软胶、哑光 ABS/PC、半透 visor 和织物肩带的复合表达,避免纯硬塑料的冷感。',
'系列规则:十二款可以有强生肖识别,但不能破坏同一机器人家族基型,不能变成十二个完全无关的动物玩偶。',
].join('\n'),
},
{
templateId: 'twelve_sku_design_matrix',
kind: 'marketing',
title: '十二款 SKU 设计矩阵',
description: '十二生肖款式命名、配色和核心卖点。',
outputFormat: 'table',
content: [
'| 款式 | 主色 | 识别元素 | 宣发关键词 |',
'| --- | --- | --- | --- |',
...VARIANTS.map(v => `| ${v.zodiac} | ${v.color} | ${v.traits} | 50cm+ AI 陪伴机器人,毛绒幻装,情绪 visor家庭空间陪伴 |`),
].join('\n'),
},
{
templateId: 'patent_filing_notes',
kind: 'patent',
title: '外观专利申报要点',
description: '专利六视图生成和申报时需要锁定的专业表达。',
outputFormat: 'bullets',
content: [
'建议按 12 个独立外观设计分别准备申报文件,每款包含正视图、后视图、左视图、右视图、俯视图、仰视图。',
'六视图应保持同一比例、同一站立中轴、同一投影逻辑,避免透视角度、场景光影和额外道具影响轮廓判断。',
'共同保护点包括情绪 visor 轮廓、胸前圆形 M 徽章、斜挎肩带、圆形侧面模块、短胖低重心结构和毛绒幻装与机器人基体的组合关系。',
'差异保护点包括每个生肖的头部幻装轮廓、表面纹样、配色组合、胸扣造型和局部毛绒材质分区。',
'尺寸文案统一为 50cm+ 智能陪伴机器人,避免在专利和宣发资料里出现小尺寸手办、小挂件或盲盒比例误导。',
].join('\n'),
},
{
templateId: 'launch_copy_package',
kind: 'marketing',
title: '宣发文案包',
description: '品牌主张、短句、详情页首屏和招商页文字。',
outputFormat: 'paragraph',
content: [
'主标题:有你家族 · 生肖幻装系列',
'副标题50cm+ 具身 AI 智能陪伴机器人,把十二生肖穿成可以回应你的温暖角色。',
'核心卖点:十二款生肖幻装、情绪 visor 互动、毛绒软壳触感、家庭空间级 50cm+ 尺寸、家族化 M 徽章与斜挎能量肩带、适合陪伴、礼赠、收藏和空间陈列。',
'详情页首屏:不是小玩偶,而是一位可以进入家庭空间的 AI 陪伴伙伴。每一款都保留有你家族的圆润机甲基型,并通过毛绒幻装、颜色和局部模块呈现专属生肖性格。',
'招商表述:该系列以统一基础结构降低产品化复杂度,以十二生肖外观系统拉开 SKU 阵列,兼具 IP 延展、节日礼赠、女性用户审美和智能硬件差异化表达。',
].join('\n'),
},
{
templateId: 'future_video_direction',
kind: 'video',
title: '后续视频方向预案',
description: '本轮视频暂缓,仅保留后续 30 秒集合片方向。',
outputFormat: 'script',
content: [
'片一十二生肖集合亮相。0-5s 情绪 visor 点亮5-15s 十二款按生肖顺序依次转身15-24s 50cm+ 家庭空间比例展示24-30s 全员定格,有你家族 · 生肖幻装系列。',
'片二亥猪主角带队。0-6s 亥猪 visor 呼吸光启动6-18s 镜头扫过其他十一款幻装18-26s 毛绒、肩带、徽章、侧面模块细节快切26-30s 亥猪站在前景,全系列在后景列阵。',
].join('\n'),
},
].map((asset, index) => ({
id: `${asset.templateId}_${index + 1}`,
templateId: asset.templateId,
kind: asset.kind,
title: asset.title,
description: asset.description,
outputFormat: asset.outputFormat,
content: asset.content,
prompt: 'manual professional text asset for zodiac fantasy series',
status: 'complete',
provider: 'mock',
model: 'manual',
createdAt: now + index,
}));
const previous = sessionValue.textAssets ?? [];
const ids = new Set(assets.map(asset => asset.templateId));
sessionValue.textAssets = [
...previous.filter(asset => !ids.has(asset.templateId)),
...assets,
];
}
async function loadOrCreateSession() {
const existing = await readJson(sessionPath);
if (existing) return existing;
const now = Date.now();
const uploadUrl = `/api/img/uploads/${sourceFilename}`;
return {
id: sessionId,
createdAt: now,
prompt: '有你家族 · 生肖幻装系列。12 款 50cm+ 具身 AI 智能陪伴机器人,毛绒幻装外观,按十二生肖展开;本轮只生成专业专利六视图、系列宣发图和文字资产,视频暂缓。',
refImages: [uploadUrl],
count: 1,
inputMode: 'replicate',
uploadedImages: [{
id: 'upload_zodiac_fantasy_source',
url: uploadUrl,
filename: sourceFilename,
originalFilename: path.basename(sourcePath),
mimeType: 'image/png',
uploadedAt: now,
role: 'subject',
needsCleanup: false,
}],
images: [{
id: 'source_zodiac_fantasy_board',
url: uploadUrl,
prompt: '有你家族 · 生肖幻装系列参考图',
status: 'selected',
meta: { sourcePath, role: 'series-reference-board' },
}],
characterSpec: {
name: '有你家族 · 生肖幻装系列',
oneLiner: '12 款 50cm+ 具身 AI 智能陪伴机器人,以统一家族基型承载十二生肖毛绒幻装。',
targetUser: '女性用户、家庭空间、礼赠用户、智能陪伴产品用户',
speciesShape: '圆润短胖 AI 机器人基型,外覆十二生肖毛绒幻装',
bodyRatio: '大头、小身体、短四肢、低重心站立,高度 50cm+',
faceFeatures: '黑色面部底层与弧形情绪 visor 显示屏',
colorPalette: ['奶白', '深灰', '生肖主题色', '高亮 visor 色', '织物肩带色'],
materials: ['短绒毛绒', '软壳 ABS/PC', '半透 visor', '织物肩带', '软胶扣件'],
accessories: ['斜挎能量肩带', '中央功能扣', '圆形侧面模块', '胸前 M 家族徽章'],
signatureElements: ['情绪 visor', 'M 徽章', '斜挎肩带', '圆润低重心', '十二生肖幻装'],
manufacturingNotes: ['高度 50cm+', '外层短绒需可清洁', '所有突出装饰必须软化圆角', '底部需稳定站立'],
patentFocus: ['十二款独立外观六视图', '统一机器人基型', '生肖幻装差异区', '50cm+ 产品比例'],
marketingAngle: ['温暖科技', '女性向陪伴', '家庭空间陈列', '生肖礼赠', '系列收藏'],
negativePrompt: '小挂件、小盲盒、小手办、低龄玩具化、重装战斗机甲、尖锐危险装饰、第三方 IP、水印、价格',
sourceImageId: 'source_zodiac_fantasy_board',
sourceImageUrl: uploadUrl,
cleanReferenceImageUrl: uploadUrl,
lockedAt: now,
},
packs: [],
textAssets: [],
videoTasks: [],
exports: [],
};
}
function ensurePack(sessionValue, kind, label) {
let pack = sessionValue.packs?.find(item => item.kind === kind);
if (pack) return pack;
const now = Date.now();
pack = {
id: `pack_${kind}_${now.toString(36)}_${randomHex(3)}`,
kind,
sessionId,
sourceImageId: 'source_zodiac_fantasy_board',
sourceImageUrl: `/api/img/uploads/${sourceFilename}`,
characterSpec: sessionValue.characterSpec,
assets: [],
manifestId: `manifest_${kind}_${now.toString(36)}`,
createdAt: now,
version: 'v01',
status: 'complete',
meta: { label },
};
sessionValue.packs = [...(sessionValue.packs || []), pack];
return pack;
}
function filterItems(items, getTarget) {
return items.filter(item => {
if (only.length && !only.includes(item.key) && !only.includes(item.templateId) && !only.includes(item.zodiac)) return false;
const { templateId, pack } = getTarget(item);
return fresh || !hasAsset(pack, templateId);
});
}
function hasAsset(pack, templateId) {
return Boolean(pack.assets?.some(asset => asset.templateId === templateId));
}
function upsertAsset(pack, asset) {
const index = pack.assets.findIndex(item => item.templateId === asset.templateId);
if (index >= 0) pack.assets[index] = asset;
else pack.assets.push(asset);
}
async function writeAsset(opts) {
const assetId = `${opts.templateId}_${randomHex(3)}`;
const filename = `${opts.pack.id}_${assetId}.png`;
const filePath = path.join(packDir, filename);
await writeDataUrl(opts.dataUrl, filePath);
return {
id: assetId,
templateId: opts.templateId,
kind: opts.kind,
view: opts.view,
title: opts.title,
description: opts.description,
url: `/api/img/packs/${filename}`,
prompt: opts.prompt,
status: 'draft',
version: 'v01',
aspectRatio: '16:9',
required: true,
createdAt: Date.now(),
anchorImageUrl: opts.anchorImageUrl,
derivationLevel: 2,
meta: { provider: 'gpt', model, ...opts.meta },
};
}
async function makeVariantCrops(inputPath, outputDir) {
for (const variant of VARIANTS) {
const output = path.join(outputDir, `${variant.key}.png`);
if (!fresh && await exists(output)) continue;
const geometry = `${variant.crop.w}x${variant.crop.h}+${variant.crop.x}+${variant.crop.y}`;
await execFile('magick', [inputPath, '-crop', geometry, '+repage', output]);
}
}
async function makeContactSheet(inputDir, outputPath) {
const tiles = [];
for (const variant of VARIANTS) {
const input = path.join(inputDir, `${variant.key}.png`);
const tile = path.join(path.dirname(outputPath), `tile_${variant.key}.png`);
await execFile('magick', [
input,
'-resize', '220x220',
'-background', 'white',
'-gravity', 'center',
'-extent', '240x240',
tile,
]);
tiles.push(tile);
}
const rows = [];
for (let index = 0; index < tiles.length; index += 4) {
const row = path.join(path.dirname(outputPath), `row_${index / 4}.png`);
await execFile('magick', [...tiles.slice(index, index + 4), '+append', row]);
rows.push(row);
}
await execFile('magick', [...rows, '-append', outputPath]);
}
async function generateEdit(opts) {
const rawText = await curlImageEdit(opts);
const raw = JSON.parse(rawText);
const first = raw.data?.[0];
if (first?.b64_json) return `data:image/png;base64,${first.b64_json}`;
if (first?.url) {
const tempPath = path.join(packDir, `tmp_${Date.now()}_${randomHex(2)}.png`);
await execFile('curl', [
'-sS',
'-L',
'--retry', '2',
'--connect-timeout', '20',
'--max-time', '300',
'-o', tempPath,
first.url,
], { maxBuffer: 1024 * 1024 });
const buffer = await fs.readFile(tempPath);
await fs.unlink(tempPath);
return `data:image/png;base64,${buffer.toString('base64')}`;
}
throw new Error('GPT image edit response missing image');
}
async function curlImageEdit(opts) {
const tempDir = path.join(root, 'artifacts', 'tmp');
await fs.mkdir(tempDir, { recursive: true });
const responsePath = path.join(tempDir, `response_${Date.now()}_${randomHex(3)}.json`);
const curlConfig = [
'silent',
'show-error',
'request = "POST"',
`url = "${apiBase}/images/edits"`,
`header = "Authorization: Bearer ${apiKey}"`,
'retry = 2',
'connect-timeout = 20',
'max-time = 600',
`output = "${responsePath}"`,
`write-out = "%{http_code}"`,
`form = "model=${model}"`,
`form = "size=${opts.size || '1536x1024'}"`,
`form = "prompt=${escapeCurlConfig(opts.prompt)}"`,
`form = "image=@${opts.imagePath};type=image/png;filename=${path.basename(opts.imagePath)}"`,
].join('\n');
try {
const stdout = await runCurlConfig(curlConfig);
const rawText = await fs.readFile(responsePath, 'utf8');
const status = Number(String(stdout).trim().slice(-3));
if (!Number.isFinite(status) || status < 200 || status >= 300) {
throw new Error(`GPT image edit ${stdout}: ${rawText}`);
}
return rawText;
} finally {
await fs.rm(responsePath, { force: true });
}
}
async function runCurlConfig(configText) {
return new Promise((resolve, reject) => {
const child = spawn('curl', ['--config', '-'], {
stdio: ['pipe', 'pipe', 'pipe'],
});
const stdout = [];
const stderr = [];
child.stdout.on('data', chunk => stdout.push(chunk));
child.stderr.on('data', chunk => stderr.push(chunk));
child.on('error', reject);
child.on('close', code => {
if (code === 0) {
resolve(Buffer.concat(stdout).toString('utf8'));
} else {
reject(new Error(`curl exited ${code}: ${Buffer.concat(stderr).toString('utf8')}`));
}
});
child.stdin.end(configText);
});
}
function escapeCurlConfig(value) {
return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\r?\n/g, '\\n');
}
async function writeTextExport(sessionValue) {
await fs.writeFile(
path.join(exportDir, `${sessionId}_text_assets.json`),
`${JSON.stringify({
sessionId,
title: '有你家族 · 生肖幻装系列文字资产',
generatedAt: new Date().toISOString(),
textAssets: sessionValue.textAssets ?? [],
}, null, 2)}\n`,
);
}
async function writeSeriesManifest(sessionValue) {
await fs.writeFile(
path.join(exportDir, `${sessionId}_series_manifest.json`),
`${JSON.stringify({
sessionId,
title: '有你家族 · 生肖幻装系列',
source: `/api/img/uploads/${sourceFilename}`,
gallery: `/api/gallery/${sessionId}`,
counts: {
images: sessionValue.images?.length ?? 0,
packs: sessionValue.packs?.length ?? 0,
packAssets: (sessionValue.packs ?? []).reduce((sum, pack) => sum + pack.assets.length, 0),
textAssets: sessionValue.textAssets?.length ?? 0,
},
variants: VARIANTS.map(({ crop, ...variant }) => variant),
packs: sessionValue.packs ?? [],
}, null, 2)}\n`,
);
}
async function saveSession(sessionValue) {
await fs.writeFile(sessionPath, `${JSON.stringify(sessionValue, null, 2)}\n`);
}
async function readJson(filePath) {
try {
return JSON.parse(await fs.readFile(filePath, 'utf8'));
} catch {
return null;
}
}
async function writeDataUrl(dataUrl, filePath) {
const match = dataUrl.match(/^data:[^;]+;base64,(.+)$/);
if (!match) throw new Error('invalid image data URL');
await fs.writeFile(filePath, Buffer.from(match[1], 'base64'));
}
async function assertFile(filePath) {
const stat = await fs.stat(filePath).catch(() => null);
if (!stat?.isFile()) fail(`Source image not found: ${filePath}`);
}
async function exists(filePath) {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
function readEnvFile(filePath) {
try {
const raw = readFileSync(filePath, 'utf8');
return Object.fromEntries(raw
.split(/\r?\n/)
.map(line => line.trim())
.filter(line => line && !line.startsWith('#') && line.includes('='))
.map(line => {
const index = line.indexOf('=');
const key = line.slice(0, index).trim();
const value = line.slice(index + 1).trim().replace(/^['"]|['"]$/g, '');
return [key, value];
}));
} catch {
return {};
}
}
function parseArgs(argv) {
const parsed = { _: [] };
for (let i = 0; i < argv.length; i += 1) {
const item = argv[i];
if (!item.startsWith('--')) {
parsed._.push(item);
continue;
}
const key = item.slice(2);
const next = argv[i + 1];
if (!next || next.startsWith('--')) {
parsed[key] = true;
} else {
parsed[key] = next;
i += 1;
}
}
return parsed;
}
function randomHex(bytes) {
return randomBytes(bytes).toString('hex');
}
function fail(message) {
console.error(message);
process.exit(1);
}