diff --git a/RULES.md b/RULES.md index ea4503c..c14bf9d 100644 --- a/RULES.md +++ b/RULES.md @@ -9,7 +9,8 @@ ## 部署事实 - 平台:个人 VPS `76.13.31.179`,Docker Compose,接入现有 Coolify Traefik - 发布状态:VPS 生产已发布,仅个人使用 -- 最近生产部署:2026-05-30,视频 provider 改为默认 Seedance(`VIDEO_PROVIDER=seedance`),OpenAI Sora 仅作为可选回退;实测 Ark / Seedance `doubao-seedance-2-0` R2V 不接受 `duration=60`,当前 Seedance 单任务按 15 秒提交。若仍需 60 秒成片,需要 4 段 15 秒后拼接,或回退 OpenAI Sora 的延展链路。 +- 最近生产部署: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。 - 服务名 / 容器名:`ai-toy-patent-workflow` - 服务器路径:`/opt/ai-toy-patent-workflow` @@ -62,7 +63,7 @@ ## 规则 - 全项目规则真源:`/Users/kangwan/Projects/code/20260317-rules-dashboard/RULES.md` - 文本/结构化/图片生成统一使用 GPT 最高规格配置 -- 视频生成默认使用 Seedance;OpenAI Sora 只作为可选回退 provider;当前 Seedance 模型不支持单次 60 秒直出 +- 视频生成默认使用 Seedance;OpenAI Sora 只作为可选回退 provider;当前 Seedance 模型不支持单次 60 秒直出,使用 `npm run videos:seedance -- --session --target-seconds 45` 按 15 秒分段生成并拼接,30 秒集合片同理设置 `--target-seconds 30` - 不允许编造不存在的部署域名、账号、密码 ## 图像链路事实 @@ -100,7 +101,7 @@ 5. 锁定角色设定 `CharacterSpec` 6. 串行生成图片包:必须从专利包开始,顺序为 `专利包 -> 配件包 -> 生产打样包 -> 宣发包` 7. 前一个图片包完整生成后,下一个图片包才解锁;不提供“一键全包”入口或全包 API -8. 四个图片包完成后,才解锁文案模板和视频任务:旋转展示、开箱、触感细节、角色故事、工厂预览;Seedance 单任务按 15 秒提交,60 秒成片需要分段拼接或使用 OpenAI Sora 回退 +8. 四个图片包完成后,才解锁文案模板和视频任务:旋转展示、开箱、触感细节、角色故事、工厂预览;Seedance 单任务按 15 秒提交,30/45/60 秒成片使用 `scripts/seedance-60s-compose.mjs` 分段拼接 9. 侧栏保留历史会话,点击切换 ## 后续路线 diff --git a/package.json b/package.json index 89dd974..630f2d1 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "docker:logs": "docker compose logs -f web", "resources:index": "node scripts/build-resource-index.mjs data", "styles:previews": "node scripts/generate-style-previews.mjs", - "videos:seedance60": "node scripts/seedance-60s-compose.mjs" + "videos:seedance": "node scripts/seedance-60s-compose.mjs", + "videos:seedance60": "node scripts/seedance-60s-compose.mjs --target-seconds 60" }, "dependencies": { "next": "^15.5.18", diff --git a/scripts/generate-hai-pig-zodiac-assets.mjs b/scripts/generate-hai-pig-zodiac-assets.mjs new file mode 100644 index 0000000..377a10c --- /dev/null +++ b/scripts/generate-hai-pig-zodiac-assets.mjs @@ -0,0 +1,297 @@ +#!/usr/bin/env node +import { createWriteStream, readFileSync } from 'node:fs'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { Readable } from 'node:stream'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const args = parseArgs(process.argv.slice(2)); +const sessionId = args.session || args._[0]; +if (!sessionId) fail('Usage: node scripts/generate-hai-pig-zodiac-assets.mjs --session [--env deploy/.env.production]'); + +const env = { + ...readEnvFile(path.join(root, args.env || '.env.local')), + ...process.env, +}; +const apiKey = env.OPENAI_API_KEY; +if (!apiKey) fail('OPENAI_API_KEY missing'); +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 fresh = Boolean(args.fresh); +const selectedTemplateIds = String(args.templates || '') + .split(',') + .map(item => item.trim()) + .filter(Boolean); +const sessionPath = path.join(root, 'data', 'sessions', `${sessionId}.json`); +const packDir = path.join(root, 'data', 'packs'); + +const ZODIAC_ASSETS = [ + { + templateId: 'zodiac_armor_grid', + view: 'zodiac-grid', + title: '十二生肖装甲全阵列', + description: '同一亥猪基型扩展出的十二生肖装甲配色和模块组合。', + aspectRatio: '16:9', + size: '1536x1024', + prompt: '12 variants grid, same base robot, different zodiac armor modules and colors', + }, + { + templateId: 'zodiac_hero_lineup', + view: 'zodiac-hero', + title: '亥猪主角十二生肖集合', + description: '亥猪作为主角,其他生肖作为同一基础机甲的装甲变化。', + aspectRatio: '16:9', + size: '1536x1024', + prompt: 'hero lineup, Hai Pig main character in center, eleven zodiac armor variants around', + }, + { + templateId: 'zodiac_material_palette', + view: 'zodiac-material', + title: '十二生肖短绒软壳材质方案', + description: '女性向软萌机甲材质、色彩与局部模块组合方案。', + aspectRatio: '16:9', + size: '1536x1024', + prompt: 'soft plush and soft shell material palette board for 12 zodiac armor variants', + }, + { + templateId: 'zodiac_module_variants', + view: 'zodiac-modules', + title: '十二生肖可变模块设计', + description: '头顶冠盖、侧面模块、肩部、手脚装甲和纹样的可变区域方案。', + aspectRatio: '16:9', + size: '1536x1024', + prompt: 'design board of changeable crown, side module, shoulder, arm and foot armor modules', + }, +]; + +await fs.mkdir(packDir, { recursive: true }); + +const session = JSON.parse(await fs.readFile(sessionPath, 'utf8')); +const pack = ensureMarketingPack(session); +const anchorUrl = findAnchor(session); +const anchorPath = localPathFromImageUrl(anchorUrl); +if (!anchorPath) fail(`Cannot resolve local anchor image: ${anchorUrl}`); + +const activeSpecs = selectedTemplateIds.length + ? ZODIAC_ASSETS.filter(spec => selectedTemplateIds.includes(spec.templateId)) + : ZODIAC_ASSETS; +const generated = []; +for (const spec of activeSpecs) { + const existing = pack.assets.find(asset => asset.templateId === spec.templateId); + if (existing && !fresh) { + generated.push({ templateId: spec.templateId, status: 'skipped', url: existing.url }); + continue; + } + const dataUrl = await generateEdit({ + prompt: zodiacPrompt(spec), + anchorPath, + size: spec.size, + }); + const assetId = `${spec.templateId}_${randomHex(3)}`; + const filename = `${pack.id}_${assetId}.png`; + const filePath = path.join(packDir, filename); + await writeDataUrl(dataUrl, filePath); + const asset = { + id: assetId, + templateId: spec.templateId, + kind: 'marketing', + view: spec.view, + title: spec.title, + description: spec.description, + url: `/api/img/packs/${filename}`, + prompt: spec.prompt, + status: 'draft', + version: 'v01', + aspectRatio: spec.aspectRatio, + required: false, + createdAt: Date.now(), + anchorImageUrl: anchorUrl, + derivationLevel: 2, + meta: { + provider: 'gpt', + model, + packLabel: '宣发包', + generatedBy: 'generate-hai-pig-zodiac-assets', + }, + }; + if (existing) { + const index = pack.assets.findIndex(item => item.templateId === spec.templateId); + pack.assets[index] = asset; + } else { + pack.assets.push(asset); + } + generated.push({ templateId: spec.templateId, status: 'generated', url: asset.url }); + await fs.writeFile(sessionPath, `${JSON.stringify(session, null, 2)}\n`); +} + +await fs.writeFile(sessionPath, `${JSON.stringify(session, null, 2)}\n`); +console.log(JSON.stringify({ sessionId, packId: pack.id, generated }, null, 2)); + +function zodiacPrompt(spec) { + return [ + '基于参考图中的“有你家族 · 亥猪”AI 陪伴机甲角色生成新的设计图板。', + '最重要:基础设计不能变。每一个形象都必须保留同一个短胖站立机甲身体、白色圆润头盔、橙色弧形 visor 情绪屏、深灰面部底层、头顶竖向 MEEY 识别条、胸前圆形 M 徽章、斜挎黑色能量肩带、灰色方形扣件、橙色功能扣、侧面圆形模块、短手短脚、黑灰脚底。', + '主题:十二生肖外观系统不是十二个动物机器人,而是同一个亥猪基型机甲的十二套可换装甲外观。亥猪是主角和母体,其它生肖只是装甲变化。', + '可变区域:头顶冠盖、侧面模块、肩部装甲、手臂装饰、腿脚边缘、表面纹样、配色、短绒/软壳材料分区。', + '禁止:猪鼻子、猪尾巴、写实猪耳、猪蹄、真实动物头、外凸兽耳、鹿角、牛角、龙角、爪、翅膀、尾巴、四足身体、武器、重装战斗机甲、第三方 IP、水印、价格、海报文案、错误尺寸标注。', + '所有生肖差异只能是贴合头盔的平面冠盖色块、平面纹样、柔软护甲片、侧面圆形模块贴片和配色变化;不能出现从头盔外伸出来的器官或尖锐装饰。', + '尺寸感:全部保持 40cm+ AI 陪伴机器人摆件体量,正面宽约 28cm、侧面深约 22cm,不能像迷你挂件。', + '材质方向:女性用户友好,白色软壳与短绒毛绒复合触感,局部深灰结构件和高识别色点缀,干净、酷、温暖,不幼稚。', + '配色建议:子鼠银蓝、丑牛象牙灰+铜金、寅虎白黑+琥珀橙、卯兔月白+樱粉、辰龙珠光白+青绿、巳蛇白+薄荷绿、午马白+赤红、未羊奶白+薰衣草、申猴白+电光黄、酉鸡白+玫瑰金、戌狗白+海军蓝、亥猪白+橙色主视觉。', + `画面要求:${spec.prompt}。16:9 横向产品设计板,白底或极浅灰棚拍,干净高级,产品可用于内部评审;尽量不生成文字标签,如果必须出现文字也要极少且不影响主体。`, + ].join('\n'); +} + +function ensureMarketingPack(sessionValue) { + const existing = sessionValue.packs?.find(item => item.kind === 'marketing'); + if (existing) return existing; + const selected = sessionValue.images?.find(image => image.status === 'selected') || sessionValue.images?.[0]; + const now = Date.now(); + const pack = { + id: `pack_marketing_${now.toString(36)}_${randomHex(3)}`, + kind: 'marketing', + sessionId, + sourceImageId: selected?.id || 'source', + sourceImageUrl: selected?.url || sessionValue.characterSpec?.sourceImageUrl || '', + characterSpec: sessionValue.characterSpec, + assets: [], + manifestId: `manifest_marketing_${now.toString(36)}`, + createdAt: now, + version: 'v01', + status: 'complete', + }; + sessionValue.packs = [...(sessionValue.packs || []), pack]; + return pack; +} + +function findAnchor(sessionValue) { + const preferredTemplateIds = [ + 'mkt_white_front', + 'patent_front', + 'prod_front_spec', + ]; + for (const templateId of preferredTemplateIds) { + const url = findAssetUrl(sessionValue, templateId); + if (url) return url; + } + if (sessionValue.characterSpec?.cleanReferenceImageUrl) return sessionValue.characterSpec.cleanReferenceImageUrl; + const selected = sessionValue.images?.find(image => image.status === 'selected') || sessionValue.images?.[0]; + if (selected?.url) return selected.url; + fail('No anchor image found'); +} + +function findAssetUrl(sessionValue, templateId) { + for (const item of sessionValue.packs || []) { + const asset = item.assets?.find(candidate => candidate.templateId === templateId); + if (asset?.url) return asset.url; + } + return undefined; +} + +function localPathFromImageUrl(url) { + const match = String(url).match(/^\/api\/img\/(generated|selected|refs|packs|anchors|uploads)\/([^/?#]+)$/); + if (!match) return null; + const dirs = { + generated: 'generated', + selected: 'selected', + refs: 'refs', + packs: 'packs', + anchors: 'anchors', + uploads: 'uploads', + }; + return path.join(root, 'data', dirs[match[1]], decodeURIComponent(match[2])); +} + +async function generateEdit(opts) { + const source = await fs.readFile(opts.anchorPath); + const form = new FormData(); + form.set('model', model); + form.set('prompt', opts.prompt); + form.set('size', opts.size || '1536x1024'); + form.set('image', new Blob([source], { type: 'image/png' }), path.basename(opts.anchorPath)); + + const res = await fetch(`${apiBase}/images/edits`, { + method: 'POST', + headers: { authorization: `Bearer ${apiKey}` }, + body: form, + }); + const rawText = await res.text(); + if (!res.ok) throw new Error(`GPT image edit ${res.status}: ${rawText}`); + 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 image = await fetch(first.url); + if (!image.ok) throw new Error(`download image ${image.status}: ${await image.text()}`); + const type = image.headers.get('content-type')?.split(';')[0] || 'image/png'; + const tempPath = path.join(packDir, `tmp_${Date.now()}_${randomHex(2)}.png`); + await streamToFile(image.body, tempPath); + const buffer = await fs.readFile(tempPath); + await fs.unlink(tempPath); + return `data:${type};base64,${buffer.toString('base64')}`; + } + throw new Error('GPT image edit response missing image'); +} + +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 streamToFile(body, filePath) { + if (!body) throw new Error('response body missing'); + await new Promise((resolve, reject) => { + const stream = createWriteStream(filePath); + Readable.fromWeb(body).pipe(stream); + stream.on('finish', resolve); + stream.on('error', reject); + }); +} + +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 readEnvFile(filePath) { + try { + const text = readFileSync(filePath, 'utf8'); + const out = {}; + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const idx = trimmed.indexOf('='); + if (idx < 0) continue; + out[trimmed.slice(0, idx)] = trimmed.slice(idx + 1).replace(/^["']|["']$/g, ''); + } + return out; + } catch { + return {}; + } +} + +function randomHex(bytes) { + return Array.from(crypto.getRandomValues(new Uint8Array(bytes))).map(byte => byte.toString(16).padStart(2, '0')).join(''); +} + +function fail(message) { + console.error(message); + process.exit(1); +} diff --git a/scripts/seedance-60s-compose.mjs b/scripts/seedance-60s-compose.mjs index 509e586..97c4f50 100644 --- a/scripts/seedance-60s-compose.mjs +++ b/scripts/seedance-60s-compose.mjs @@ -6,9 +6,15 @@ import path from 'node:path'; import { Readable } from 'node:stream'; import { fileURLToPath } from 'node:url'; -const SEGMENT_SECONDS = 15; -const SEGMENT_COUNT = 4; -const TARGET_SECONDS = SEGMENT_SECONDS * SEGMENT_COUNT; +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const args = parseArgs(process.argv.slice(2)); +const command = args._[0] || 'run'; +const sessionId = args.session || args._[1]; +if (!sessionId) fail('Usage: node scripts/seedance-60s-compose.mjs run --session [--target-seconds 45] [--env deploy/.env.production]'); + +const SEGMENT_SECONDS = clampNumber(args['segment-seconds'], 15, 3, 15); +const TARGET_SECONDS = clampNumber(args['target-seconds'] || args.seconds, 45, SEGMENT_SECONDS, 180); +const SEGMENT_COUNT = Math.ceil(TARGET_SECONDS / SEGMENT_SECONDS); const TEMPLATE_IDS = [ 'video_turntable', 'video_unboxing', @@ -16,19 +22,7 @@ const TEMPLATE_IDS = [ 'video_story_intro', 'video_factory_preview', ]; -const PART_CUES = [ - '第 1 段:建立镜头,先稳定展示 45cm 高、正面约 32cm 宽的大号糯糯猪整体体量、圆胖坐姿和核心识别点。', - '第 2 段:继续同一只玩偶,推进到侧面约 28cm 深、背面约 33cm 宽、耳朵、鼻子、吊牌和毛绒细节,不改变外观。', - '第 3 段:保持 45cm 大号比例,加入成人双手抱持、前臂托住或胸前怀抱,让玩偶显得可拥抱且超过 40cm。', - '第 4 段:回到产品展示收束,补充包装、材料、生产或温暖陪伴镜头,包装/手部比例必须支持 45cm 成品尺寸。', -]; - -const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -const args = parseArgs(process.argv.slice(2)); -const command = args._[0] || 'run'; -const sessionId = args.session || args._[1]; -if (!sessionId) fail('Usage: node scripts/seedance-60s-compose.mjs run --session [--env deploy/.env.production]'); -const runLabel = safe(args['run-label'] || 'seedance60'); +const runLabel = safe(args['run-label'] || `seedance${TARGET_SECONDS}`); const freshRun = Boolean(args.fresh); const selectedTemplateIds = String(args.templates || '') .split(',') @@ -36,6 +30,51 @@ const selectedTemplateIds = String(args.templates || '') .filter(Boolean); const activeTemplateIds = selectedTemplateIds.length ? selectedTemplateIds : TEMPLATE_IDS; +const VIDEO_TEMPLATE_BLUEPRINTS = { + video_turntable: { + title: '360 度旋转展示', + description: `${TARGET_SECONDS} 秒,用于电商和内部评审,展示整体体积、正背侧轮廓。`, + ratio: '16:9', + prompt: character => `生成 ${TARGET_SECONDS} 秒 360 度旋转展示视频:${character}. 白底或浅灰棚拍,镜头稳定,产品缓慢旋转,展示正面、侧面、背面、顶部细节,材质、表面质感、情绪屏和配件必须严格贴合角色设定。产品尺寸按 40cm+ 智能陪伴机器人摆件表现,正面宽约 28cm、侧深约 22cm,镜头中要能感知 40cm 以上实体体量。音乐氛围可参考韩流电子节奏和潮流鼓点,但不要使用真实受版权保护歌曲。`, + }, + video_unboxing: { + title: '开箱短片', + description: `${TARGET_SECONDS} 秒,用于新品宣发,展示包装到玩具出现的过程。`, + ratio: '9:16', + prompt: character => `生成 ${TARGET_SECONDS} 秒玩具开箱短片:${character}. 竖版社媒风格,从礼盒或包装打开到产品出现,温暖但克制的棚拍光线,突出礼物感、收藏感、角色识别点和配件陈列。产品为 40cm+ 智能陪伴机器人摆件,包装和手部比例必须支持 40cm 以上尺寸。音乐氛围可参考韩流电子节奏和潮流鼓点,但不要使用真实受版权保护歌曲。`, + }, + video_touch_detail: { + title: '触感细节', + description: `${TARGET_SECONDS} 秒,展示材质、情绪屏、表面纹理和配件细节。`, + ratio: '9:16', + prompt: character => `生成 ${TARGET_SECONDS} 秒玩具细节短片:${character}. 近景镜头,展示橙色 visor 情绪屏、白色短绒或软壳触感、深灰结构件、MEEY 顶部识别条、胸前 M 徽章、斜挎肩带、灰橙扣件、侧面圆形模块和脚底细节,节奏清楚,避免加入设定外部件。必须体现 40cm+ 产品的厚实体量、柔和触感和稳定站立尺度。音乐氛围可参考韩流电子节奏和潮流鼓点,但不要使用真实受版权保护歌曲。`, + }, + video_story_intro: { + title: '角色故事介绍', + description: `${TARGET_SECONDS} 秒,用于 IP 设定和社媒发布。`, + ratio: '16:9', + prompt: character => `生成 ${TARGET_SECONDS} 秒玩具角色故事介绍视频:${character}. 轻剧情镜头,围绕“猪族世界的第一套十二生肖机甲基型”登场,展示情绪屏变化、标志性配件、色彩气质和陪伴感,适合新品发布。故事中产品始终是 40cm+ 智能陪伴机器人摆件,可以被双手抱住或稳放在家居空间,不要缩成桌面小摆件。音乐氛围可参考韩流电子节奏和潮流鼓点,但不要使用真实受版权保护歌曲。`, + }, + video_factory_preview: { + title: '工厂预览短片', + description: `${TARGET_SECONDS} 秒,用于打样前内部沟通,展示外观、尺寸、材料、拆件和包装要点。`, + ratio: '16:9', + prompt: character => `生成 ${TARGET_SECONDS} 秒工厂预览概念短片:${character}. 16:9,面向内部沟通,展示外观、尺寸、材料、拆件、装配和包装要点,镜头清楚克制,不做消费者营销话术。尺寸基准写死为成品高度 40cm+,正面宽约 28cm,侧面深约 22cm,必须保持 40cm 以上实体产品尺度。音乐氛围可参考韩流电子节奏和潮流鼓点,但不要使用真实受版权保护歌曲。`, + }, + zodiac_collection_hero: { + title: '十二生肖集合主视觉短片', + description: `${TARGET_SECONDS} 秒,亥猪作为主角展示十二生肖装甲系统。`, + ratio: '16:9', + prompt: character => `生成 ${TARGET_SECONDS} 秒十二生肖集合主视觉短片:${character}. 亥猪是主角,站在画面中心或首先登场,其他十一套生肖装甲作为同一基础机甲的外观扩展依次出现。每一套都必须保持同一白色圆润头盔、橙色弧形 visor、深灰面部底层、MEEY 顶部识别条、胸前 M 徽章、斜挎肩带、短胖低重心比例,只变化头顶冠盖、侧面模块、肩部/手臂/脚部装甲纹样、配色和短绒/软壳材质。不要把其它生肖变成真实动物身体;不要出现猪鼻子、尾巴、角、爪、翅膀等具象器官。潮流电子节奏,酷炫但温暖,适合系列发布。`, + }, + zodiac_collection_runway: { + title: '十二生肖装甲走秀短片', + description: `${TARGET_SECONDS} 秒,展示十二生肖外观组合,亥猪压轴/领队。`, + ratio: '16:9', + prompt: character => `生成 ${TARGET_SECONDS} 秒十二生肖装甲走秀短片:${character}. 以亥猪基型为领队,十二套外观像产品发布走秀一样在白色科技展台上轮换展示。颜色可以更丰富,但基础形象不能变:橙色 visor 情绪屏、胸前 M 徽章、斜挎黑色能量肩带、灰橙扣件和短胖站姿全部保留。每个生肖只通过可换装甲区域、色彩符号和表面短绒/软壳材料区分,禁止真实动物器官、禁止重装战斗化。镜头要有节奏感、旋转台、局部特写和集合收束,音乐为原创韩流感电子鼓点,不使用真实歌曲。`, + }, +}; + const env = { ...readEnvFile(path.join(root, args.env || '.env.local')), ...process.env, @@ -97,6 +136,12 @@ function parseArgs(argv) { return parsed; } +function clampNumber(value, fallback, min, max) { + const parsed = Number(value ?? fallback); + if (!Number.isFinite(parsed)) return fallback; + return Math.min(Math.max(Math.trunc(parsed), min), max); +} + function readEnvFile(filePath) { try { const text = readFileSync(filePath, 'utf8'); @@ -132,8 +177,9 @@ async function ensureTracker() { const anchor = findAnchor(session); const referenceUrls = findVideoReferenceUrls(session, anchor); + const character = characterSummary(session); for (const templateId of activeTemplateIds) { - const task = (session.videoTasks || []).find(item => item.templateId === templateId); + const task = (session.videoTasks || []).find(item => item.templateId === templateId) || buildDefaultVideoTask(session, templateId, character, anchor); if (!task) continue; const entry = tracker.templates[templateId] || { templateId, @@ -171,6 +217,41 @@ async function ensureTracker() { return tracker; } +function buildDefaultVideoTask(session, templateId, character, anchorImageUrl) { + const blueprint = VIDEO_TEMPLATE_BLUEPRINTS[templateId]; + if (!blueprint) return null; + const now = Date.now(); + return { + id: `vid_${session.id}_${templateId}`, + templateId, + title: blueprint.title, + description: blueprint.description, + prompt: blueprint.prompt(character), + anchorImageUrl, + provider: 'seedance', + model, + status: 'processing', + ratio: blueprint.ratio, + duration: TARGET_SECONDS, + submittedAt: now, + updatedAt: now, + }; +} + +function characterSummary(session) { + const spec = session.characterSpec || {}; + const parts = [ + spec.name || '有你家族 · 亥猪', + spec.oneLiner, + spec.bodyRatio, + spec.faceFeatures, + spec.signatureElements?.length ? `固定识别元素:${spec.signatureElements.join('、')}` : '', + spec.materials?.length ? `材质方向:${spec.materials.join('、')}` : '', + spec.negativePrompt ? `禁忌:${spec.negativePrompt}` : '', + ].filter(Boolean); + return parts.join(';'); +} + async function submitMissingSegments(tracker) { for (const entry of Object.values(tracker.templates)) { for (const segment of entry.segments) { @@ -306,7 +387,7 @@ async function downloadVideo(sessionIdValue, templateId, part, taskId, url) { } async function composeTemplate(entry) { - const outputName = `${safe(sessionId)}_${safe(entry.templateId)}_${runLabel}_60s.mp4`; + const outputName = `${safe(sessionId)}_${safe(entry.templateId)}_${runLabel}_${TARGET_SECONDS}s.mp4`; const outputPath = path.join(videosDir, outputName); const listPath = path.join(segmentDir, `${safe(entry.templateId)}_concat.txt`); const list = entry.segments @@ -328,10 +409,12 @@ async function composeTemplate(entry) { async function updateFinalTask(entry) { const session = await readSession(); const index = (session.videoTasks || []).findIndex(task => task.templateId === entry.templateId); - if (index < 0) throw new Error(`session video task missing: ${entry.templateId}`); const now = Date.now(); - const current = session.videoTasks[index]; - session.videoTasks[index] = { + const current = index >= 0 + ? session.videoTasks[index] + : buildDefaultVideoTask(session, entry.templateId, characterSummary(session), entry.anchorImageUrl); + if (!current) throw new Error(`session video task missing: ${entry.templateId}`); + const nextTask = { ...current, provider: 'seedance', model, @@ -341,7 +424,7 @@ async function updateFinalTask(entry) { updatedAt: now, raw: { ...(typeof current.raw === 'object' && current.raw ? current.raw : {}), - seedance60: { + seedanceSegmented: { runLabel, targetSeconds: TARGET_SECONDS, segmentSeconds: SEGMENT_SECONDS, @@ -355,6 +438,9 @@ async function updateFinalTask(entry) { }, }, }; + session.videoTasks = session.videoTasks || []; + if (index >= 0) session.videoTasks[index] = nextTask; + else session.videoTasks.push(nextTask); await fs.writeFile(sessionPath, `${JSON.stringify(session, null, 2)}\n`); } @@ -367,11 +453,13 @@ async function updateAllProgress(tracker) { async function updateSessionProgress(entry) { const session = await readSession(); const index = (session.videoTasks || []).findIndex(task => task.templateId === entry.templateId); - if (index < 0) return; const now = Date.now(); - const current = session.videoTasks[index]; + const current = index >= 0 + ? session.videoTasks[index] + : buildDefaultVideoTask(session, entry.templateId, characterSummary(session), entry.anchorImageUrl); + if (!current) return; const firstTaskId = entry.segments.find(segment => segment.taskId)?.taskId; - session.videoTasks[index] = { + const nextTask = { ...current, provider: 'seedance', model, @@ -382,7 +470,7 @@ async function updateSessionProgress(entry) { updatedAt: now, raw: { ...(typeof current.raw === 'object' && current.raw ? current.raw : {}), - seedance60: { + seedanceSegmented: { runLabel, targetSeconds: TARGET_SECONDS, segmentSeconds: SEGMENT_SECONDS, @@ -396,6 +484,9 @@ async function updateSessionProgress(entry) { }, }, }; + session.videoTasks = session.videoTasks || []; + if (index >= 0) session.videoTasks[index] = nextTask; + else session.videoTasks.push(nextTask); await fs.writeFile(sessionPath, `${JSON.stringify(session, null, 2)}\n`); } @@ -412,14 +503,27 @@ function segmentPrompt(prompt, part) { prompt.trim(), '', `这是 Seedance 分段生成的第 ${part}/${SEGMENT_COUNT} 段,每段 ${SEGMENT_SECONDS} 秒,最终会拼成 ${TARGET_SECONDS} 秒完整视频。`, - PART_CUES[part - 1], - '硬性尺寸约束:主角始终是“有你家族 · 糯糯猪”智能陪伴毛绒娃娃,高度约 45cm,正面宽约 32cm,侧深约 28cm,背面宽约 33cm。', - '必须明显是 40cm 以上的大尺寸抱抱玩偶:优先用成人双手、前臂、胸前怀抱、包装盒、椅子或床品证明比例;不能像掌心小玩偶、桌面摆件、挂件或钥匙扣。', + partCue(part), + '硬性尺寸约束:主角始终是“有你家族 · 亥猪”40cm+ AI 陪伴机甲摆件,高度必须超过 40cm,正面宽约 28cm,侧面深约 22cm。', + '必须明显是 40cm 以上的实体产品:可用成人双手、包装盒、展台、桌面或家居物件证明比例;不能像掌心小玩偶、桌面迷你摆件、挂件或钥匙扣。', '参考图里的中文和数字只用于理解尺寸比例;成片画面中不要生成任何数字、厘米文字、箭头尺寸标注或文字海报,避免出现错误读数。', - '保持浅粉长绒、圆胖坐姿、黑亮眼睛、粉色猪鼻、下垂耳朵、金色挂绳和爱心吊牌,不要变成小挂件、桌面小摆件或其它动物。', + '必须保留白色圆润头盔、橙色弧形 visor 情绪屏、深灰面部底层、头顶 MEEY 竖条、胸前 M 徽章、斜挎黑色能量肩带、灰橙功能扣、侧面圆形模块、短胖站立比例。', + '外部可呈现亲肤短绒、软壳或软硅胶复合触感,但不能改变基础机甲设计。禁止猪鼻子、猪尾巴、写实猪耳、猪蹄、四足身体、其它动物主体、武器和攻击性重装机甲。', ].join('\n'); } +function partCue(part) { + if (SEGMENT_COUNT <= 2) { + return part === 1 + ? '第 1 段:建立系列世界观和主角登场,亥猪先出现,镜头给足正面、肩带、徽章、情绪屏和 40cm+ 体量。' + : '第 2 段:进入集合高潮和收束,展示装甲变化、局部细节、全体阵列或旋转台,最后回到亥猪主角。'; + } + if (part === 1) return '第 1 段:建立镜头,先稳定展示亥猪整体体量、正面轮廓、橙色 visor、胸前徽章和斜挎肩带。'; + if (part === 2) return '第 2 段:继续同一只产品,推进到侧面、背面、顶部 MEEY 识别条、侧面圆形模块和短绒/软壳材质细节。'; + if (part === SEGMENT_COUNT) return '最后一段:回到完整产品展示收束,补充包装、材料、生产或集合场景,明确 40cm+ 成品尺度。'; + return `第 ${part} 段:保持同一角色和同一尺寸,增加使用场景、局部特写、触感互动或系列装甲转换,不改变基础设计。`; +} + function normalizeStatus(status) { if (status === 'succeeded' || status === 'success' || status === 'completed') return 'succeeded'; if (status === 'failed' || status === 'error') return 'failed'; @@ -449,6 +553,10 @@ function findAnchor(session) { function findVideoReferenceUrls(session, fallbackUrl) { const preferredTemplateIds = [ + 'zodiac_hero_lineup', + 'zodiac_armor_grid', + 'zodiac_material_palette', + 'zodiac_module_variants', 'prod_dimension_overall', 'mkt_white_front', 'mkt_white_back', diff --git a/src/lib/videoProviders.ts b/src/lib/videoProviders.ts index fb690cf..527b3b6 100644 --- a/src/lib/videoProviders.ts +++ b/src/lib/videoProviders.ts @@ -113,10 +113,12 @@ function withProductVideoConstraints(prompt: string, targetDuration: number): st return [ prompt.trim(), '', - '硬性尺寸约束:主角必须是“有你家族 · 糯糯猪”智能陪伴毛绒娃娃,整体成品高度约 45cm,正面宽约 32cm,侧深约 28cm,背面宽约 33cm。', - '必须明显是 40cm 以上的大尺寸抱抱玩偶:优先用成人双手、前臂、胸前怀抱、包装盒、椅子或床品证明比例;不能像掌心小玩偶、桌面摆件、挂件或钥匙扣。', - '如果参考图有中文或数字,只把它们当作尺寸比例依据;成片中不要生成任何数字、厘米文字、箭头尺寸标注或文字海报,避免出现错误读数。', - '保持浅粉长绒、圆胖坐姿、黑亮眼睛、粉色猪鼻、下垂耳朵、金色挂绳和爱心吊牌;不要改成普通小挂件、钥匙扣或低于 40cm 的小公仔。', + '硬性一致性约束:主角必须严格服从当前锁定角色和参考图。当前项目主角是“有你家族 · 亥猪”40cm+ AI 陪伴机甲摆件,不是传统卡通猪。', + '必须保留白色圆润头盔、橙色弧形 visor 情绪屏、深灰面部底层、头顶 MEEY 竖条、胸前 M 徽章、斜挎黑色能量肩带、灰橙功能扣、侧面圆形模块、短胖低重心站立比例。', + '尺寸表现按 40cm+ 实体产品处理:正面宽约 28cm,侧面深约 22cm,可以用成人双手、包装盒、桌面或展台帮助体现体量;不能缩成掌心小玩偶、钥匙扣、迷你挂件或低于 40cm 的小公仔。', + '外部材质可以是亲肤短绒、软壳或软硅胶复合质感,但不能改变基础机甲轮廓、面罩形状、肩带路径、徽章位置和核心配件关系。', + '禁止生成猪鼻子、猪尾巴、写实猪耳、猪蹄、四足动物身体、其它生肖动物本体、武器和攻击性重装机甲。', + '如果参考图有中文或数字,只把它们当作比例依据;成片中不要生成任何数字、厘米文字、箭头尺寸标注或文字海报,避免出现错误读数。', `目标视频总时长不少于 ${targetDuration} 秒;如果 API 需要分段或延展,保持同一角色、同一尺寸比例和连续镜头语言。`, ].join('\n'); }