Files
ai-toy-patent-workflow/scripts/seedance-60s-compose.mjs

767 lines
42 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
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 command = args._[0] || 'run';
const sessionId = args.session || args._[1];
if (!sessionId) fail('Usage: node scripts/seedance-60s-compose.mjs run --session <sessionId> [--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 PRODUCT_CONTEXT = productContextForSession(sessionId);
const PRODUCT_SIZE_LABEL = args['product-size'] || PRODUCT_CONTEXT.sizeLabel;
const PRODUCT_SIZE_TEXT = args['product-size-text'] || PRODUCT_CONTEXT.sizeText;
const TEMPLATE_IDS = [
'video_turntable',
'video_unboxing',
'video_touch_detail',
'video_story_intro',
'video_factory_preview',
];
const runLabel = safe(args['run-label'] || `seedance${TARGET_SECONDS}`);
const freshRun = Boolean(args.fresh);
const noReferenceImages = Boolean(args['no-reference']);
const selectedTemplateIds = String(args.templates || '')
.split(',')
.map(item => item.trim())
.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 徽章、斜挎黑色能量肩带、灰橙扣件和短胖站姿全部保留。每个生肖只通过可换装甲区域、色彩符号和表面短绒/软壳材料区分,禁止真实动物器官、禁止重装战斗化。镜头要有节奏感、旋转台、局部特写和集合收束,音乐为原创韩流感电子鼓点,不使用真实歌曲。`,
},
zodiac_fantasy_pig_showcase: {
title: '亥猪 C 位展示视频',
description: `${TARGET_SECONDS} 秒,亥猪作为 C 位主角,其它 11 款生肖幻装作为背景阵列。`,
ratio: '16:9',
prompt: character => `生成 ${TARGET_SECONDS} 秒“有你家族 · 生肖幻装系列 · 亥猪 C 位展示视频”:${character}. 亥猪必须始终是画面 C 位和视觉焦点,粉色猪系毛绒幻装、粉紫情绪 visor、M 家族徽章、斜挎能量肩带、圆润机甲基型必须清晰保留。其它 11 款生肖机器人只能作为背景阵列、舞台两侧、虚化陈列或后景灯箱出现,不抢主角。镜头结构:开场亥猪 visor 点亮,随后 360 度慢旋展示正面、侧面、背面和顶部,再用展台灯光扫过背景十二生肖阵列,最后亥猪回到正中定格。硬性尺度:全系列都是 50cm+ 具身 AI 智能陪伴机器人,不是小手办。画面风格酷炫、干净、女性向高级感,原创韩流感电子节奏,不使用真实受版权保护歌曲。`,
},
zodiac_fantasy_pig_touch_detail: {
title: '亥猪 C 位触感细节',
description: `${TARGET_SECONDS}展示亥猪毛绒软壳、visor、徽章、肩带和脚底细节。`,
ratio: '9:16',
prompt: character => `生成 ${TARGET_SECONDS} 秒“亥猪 C 位触感细节视频”:${character}. 竖版近景,亥猪是唯一主角,其他生肖只作为远处柔焦背景或镜面反射,不抢画面。重点展示粉色短绒毛绒外层、软壳机甲基体、粉紫 visor 情绪屏呼吸光、胸前 M 徽章、斜挎肩带、猪系胸扣、侧面圆形模块、圆润手臂、稳定脚底。需要有手部轻触、指尖划过短绒、按压软壳边缘、visor 亮起回应的细节,但手部比例必须证明这是 50cm+ 大体量陪伴机器人。禁止把亥猪拍成小玩偶、钥匙扣或桌面小公仔。音乐是原创韩流电子节奏和干净鼓点,不使用真实歌曲。`,
},
zodiac_fantasy_pig_group_flash: {
title: '亥猪 C 位群体快闪',
description: `${TARGET_SECONDS} 秒,十二生肖群体快闪,亥猪领舞/领队/C 位收束。`,
ratio: '16:9',
prompt: character => `生成 ${TARGET_SECONDS} 秒“十二生肖群体快闪视频”:${character}. 亥猪必须是 C 位领队,先单独登场,再由其它 11 款生肖幻装机器人从左右和后方快闪入场形成阵列。其它角色只做背景节奏、队形变化和颜色补充,不改变亥猪主角身份。镜头节奏:快速灯光点亮、生肖角色依次闪现、亥猪向前一步、全员 visor 同步呼吸光、群体 3 排阵列收束。所有角色都是 50cm+ 具身 AI 智能陪伴机器人,体量接近家居摆件/陪伴设备,不是盲盒。风格要酷炫、潮流、女性向高级,原创韩流感电子鼓点,不使用真实受版权保护歌曲。`,
},
zodiac_fantasy_interaction_showcase: {
title: '生肖幻装系列互动展示视频',
description: `${TARGET_SECONDS} 秒,亥猪作为系列代表展示屏幕脸、表情包、视频播放和人机互动。`,
ratio: '16:9',
prompt: character => `生成 ${TARGET_SECONDS} 秒“有你家族 · 生肖幻装系列互动展示视频”:${character}. 以系列中的亥猪为主要展示角色,其他生肖幻装机器人可以作为背景阵列、舞台两侧陈列或远景队列。亥猪面部必须是电子显示屏/情绪 visor屏幕上清楚变化喜、怒、哀、乐、害羞、惊喜等表情包并出现与用户语音互动、点头回应、灯效呼吸、播放短视频画面的场景。额头上的猪鼻子这条视频可以不要显示不要把猪鼻子做成突出的额头装饰保留粉色猪系幻装、M 家族徽章、斜挎能量肩带和圆润 50cm+ 具身 AI 陪伴机器人比例。镜头结构:开场系列展台点亮,亥猪屏幕脸从待机图标切换成表情包;中段展示用户说话、亥猪屏幕回应并播放小视频;后段其它生肖作为背景一起亮屏,最后回到亥猪正面定格。风格干净高级、亲和、有科技感,原创电子节奏,不使用真实受版权保护歌曲。`,
},
hai_pig_meey_interaction_showcase: {
title: '亥猪 MEEY 互动展示视频',
description: `${TARGET_SECONDS} 秒,展示屏幕脸、表情包、视频播放和陪伴交互。`,
ratio: '16:9',
prompt: character => `生成 ${TARGET_SECONDS} 秒“有你家族 · 亥猪 MEEY 机甲陪伴机器人互动展示视频”:${character}. 主角是 40cm+ 亥猪 MEEY 机甲陪伴机器人白色圆润机甲头盔、橙色弧形显示屏脸、深灰面部底层、MEEY 顶部识别条、胸前 M 徽章、斜挎能量肩带和灰橙功能扣必须稳定一致。面部显示器要清楚呈现喜、怒、哀、乐、撒娇、确认、睡眠等表情包切换,并展示用户靠近说话、机器人屏幕文字/表情回应、播放家庭短视频或动画片段、用灯效和轻微身体动作反馈的场景。镜头结构:开场桌面/客厅真实比例亮相;中段重点拍屏幕脸表情包和视频播放;后段成人手部或儿童在旁边互动,机器人转头/屏幕回应;最后产品完整正面收束。不要变成真实猪、毛绒动物或小手办。风格温暖科技、适合新品展示,原创电子节奏,不使用真实受版权保护歌曲。`,
},
youni_family_meey_screen_showcase: {
title: '有你家族 MEEY 屏幕脸展示视频',
description: `${TARGET_SECONDS} 秒,面部无遮挡显示屏,展示表情包、演唱会视频播放和有你家族陪伴主题。`,
ratio: '16:9',
prompt: character => `生成 ${TARGET_SECONDS} 秒“有你家族 · MEEY 机甲陪伴机器人屏幕脸展示视频”:${character}. 主题是“有你家族”,主角是亥猪 MEEY 机甲陪伴机器人但画面表达要更像品牌家族成员的智能陪伴产品发布片。最重要硬性要求机器人的面部就是一整块完整电子显示屏屏幕必须无遮挡、无护目镜遮挡、无金属横条挡住、无口罩、无外壳压住显示区域正面镜头要让观众清楚看见完整屏幕边界和屏幕内容。屏幕内容要不断变化开心、委屈、撒娇、惊喜、害羞、睡眼、爱心、确认等表情包中段必须出现播放演唱会视频的场景屏幕里有舞台灯光、歌手剪影、观众光棒或音乐可视化但不要使用真实明星肖像、真实歌曲名、真实品牌标识或可识别版权演唱会。机身保持白色圆润头盔、MEEY 顶部识别条、胸前 M 徽章、斜挎能量肩带、灰橙功能扣、短胖 40cm+ 陪伴机器人比例。镜头结构:开场“有你家族”温暖家庭空间中产品亮屏;中段大特写展示无遮挡全屏表情包切换和演唱会播放;后段用户语音点播音乐会,机器人屏幕回应并播放舞台画面;最后回到正面完整屏幕露出微笑表情收束。风格温暖、科技、家庭陪伴、品牌发布感,原创电子音乐氛围,不使用真实受版权保护歌曲。`,
},
nuonuo_pig_interaction_showcase: {
title: '糯糯猪互动展示视频',
description: `${TARGET_SECONDS} 秒,展示自主行动、语音互动和猪鼻/耳朵/尾巴/眼睛触摸反馈。`,
ratio: '16:9',
prompt: character => `生成 ${TARGET_SECONDS} 秒“有你家族 · 糯糯猪智能陪伴毛绒玩具互动展示视频”:${character}. 主角是约 45cm 大尺寸浅粉色长绒毛糯糯猪,圆胖坐姿、黑亮圆眼睛、粉色立体猪鼻、下垂猪耳朵、短卷尾巴、金色挂绳项圈和爱心吊牌必须保持一致。视频必须体现它不是普通静态毛绒玩具,而是能自主行动、能和人语音互动的智能陪伴玩具:可以轻微前进/转身/抬头点头,听到用户说话后用软萌语音、眼部灯效、鼻尖呼吸光、耳朵轻动、尾巴摇动回应。必须安排人机互动镜头:用户摸猪鼻子时鼻尖发光并发出回应;摸耳朵时耳朵轻摆并听懂语音;摸尾巴时尾巴卷动或抖动反馈;看向眼睛时眼睛亮起、眨眼或显示情绪光。镜头结构:开场从沙发/儿童房自主走近;中段语音问答与触摸反馈;后段拥抱陪伴、睡前故事或亲子互动;最后糯糯猪坐回中心微笑收束。不要改成机甲机器人、不要失去毛绒小猪身份,不要变成小挂件。风格温暖治愈、真实家庭使用场景,原创轻快音乐,不使用真实受版权保护歌曲。`,
},
};
const env = {
...readEnvFile(path.join(root, args.env || '.env.local')),
...process.env,
};
const apiKey = env.SEEDANCE_API_KEY;
if (!apiKey) fail('SEEDANCE_API_KEY missing');
const apiBase = env.SEEDANCE_API_BASE || 'https://ark.cn-beijing.volces.com/api/v3';
const model = env.SEEDANCE_MODEL || 'doubao-seedance-2-0-260128';
const publicAppUrl = args['public-app-url'] || env.PUBLIC_APP_URL || env.NEXT_PUBLIC_APP_URL || 'https://ai-toy.kang-kang.com';
const sessionPath = path.join(root, 'data', 'sessions', `${sessionId}.json`);
const trackerDir = path.join(root, 'data', 'video-segments');
const trackerPath = path.join(trackerDir, `${sessionId}-${runLabel}.json`);
const videosDir = path.join(root, 'data', 'videos');
const segmentDir = path.join(videosDir, 'segments', sessionId, runLabel);
await fs.mkdir(trackerDir, { recursive: true });
await fs.mkdir(segmentDir, { recursive: true });
if (command === 'submit') {
const tracker = await ensureTracker();
if (freshRun) await updateAllProgress(tracker);
await submitMissingSegments(tracker);
await saveTracker(tracker);
printSummary(tracker);
} else if (command === 'poll') {
const tracker = await ensureTracker();
await pollAndCompose(tracker, { once: true });
await saveTracker(tracker);
printSummary(tracker);
} else if (command === 'run') {
const tracker = await ensureTracker();
if (freshRun) await updateAllProgress(tracker);
await submitMissingSegments(tracker);
await saveTracker(tracker);
await pollAndCompose(tracker, { once: Boolean(args.once) });
await saveTracker(tracker);
printSummary(tracker);
} else {
fail(`Unknown command: ${command}`);
}
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 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');
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 {};
}
}
async function ensureTracker() {
const session = await readSession();
let tracker = await readTracker();
if (!tracker) {
tracker = {
sessionId,
model,
provider: 'seedance',
targetSeconds: TARGET_SECONDS,
segmentSeconds: SEGMENT_SECONDS,
createdAt: Date.now(),
updatedAt: Date.now(),
templates: {},
};
}
const anchor = findAnchor(session);
const referenceUrls = noReferenceImages ? [] : findVideoReferenceUrls(session, anchor);
const character = characterSummary(session);
for (const templateId of activeTemplateIds) {
const task = (session.videoTasks || []).find(item => item.templateId === templateId) || buildDefaultVideoTask(session, templateId, character, anchor);
if (!task) continue;
const entry = tracker.templates[templateId] || {
templateId,
title: task.title,
ratio: task.ratio || '16:9',
prompt: task.prompt,
anchorImageUrl: anchor,
referenceUrls,
finalVideoUrl: undefined,
segments: [],
};
entry.title = entry.title || task.title;
entry.ratio = entry.ratio || task.ratio || '16:9';
entry.prompt = entry.prompt || task.prompt;
entry.anchorImageUrl = entry.anchorImageUrl || anchor;
entry.referenceUrls = entry.referenceUrls?.length ? entry.referenceUrls : referenceUrls;
if (!freshRun && task.taskId && !entry.segments.some(segment => segment.part === 1)) {
entry.segments.push({
part: 1,
taskId: task.taskId,
status: task.status || 'submitted',
source: 'existing_session_task',
submittedAt: task.submittedAt || Date.now(),
});
}
for (let part = 1; part <= SEGMENT_COUNT; part += 1) {
if (!entry.segments.some(segment => segment.part === part)) {
entry.segments.push({ part, status: 'pending' });
}
}
entry.segments.sort((a, b) => a.part - b.part);
tracker.templates[templateId] = entry;
}
tracker.updatedAt = Date.now();
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) {
if (segment.taskId || segment.status === 'succeeded') continue;
const response = await submitSegment(entry, segment.part);
segment.taskId = response.taskId;
segment.status = response.status;
segment.raw = response.raw;
segment.submittedAt = Date.now();
console.log(JSON.stringify({
action: 'submitted',
templateId: entry.templateId,
part: segment.part,
taskId: segment.taskId,
status: segment.status,
}));
await updateSessionProgress(entry);
await saveTracker(tracker);
}
}
}
async function submitSegment(entry, part) {
const body = {
model,
content: [
{ type: 'text', text: segmentPrompt(entry, part) },
...entry.referenceUrls.map(url => ({
type: 'image_url',
image_url: { url: publicUrl(url) },
role: 'reference_image',
})),
],
generate_audio: true,
ratio: entry.ratio || '16:9',
duration: SEGMENT_SECONDS,
watermark: false,
};
const res = await fetch(`${apiBase}/contents/generations/tasks`, {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(body),
});
const rawText = await res.text();
if (!res.ok) throw new Error(`Seedance submit ${res.status}: ${rawText}`);
const raw = JSON.parse(rawText);
return {
taskId: raw.task_id || raw.id,
status: normalizeStatus(raw.status),
raw,
};
}
async function pollAndCompose(tracker, opts) {
const maxWaitMs = Number(args['max-wait-minutes'] || 90) * 60 * 1000;
const pollMs = Number(args['poll-seconds'] || 45) * 1000;
const started = Date.now();
while (true) {
let changed = false;
for (const entry of Object.values(tracker.templates)) {
let entryChanged = false;
for (const segment of entry.segments) {
if (!segment.taskId || segment.status === 'succeeded' || segment.status === 'failed') continue;
const response = await getTask(segment.taskId);
segment.status = response.status;
segment.raw = response.raw;
segment.updatedAt = Date.now();
changed = true;
entryChanged = true;
if (response.videoUrl) {
segment.remoteVideoUrl = response.videoUrl;
}
if (response.status === 'succeeded') {
segment.filePath = await downloadVideo(sessionId, entry.templateId, segment.part, segment.taskId, response.videoUrl);
console.log(JSON.stringify({
action: 'downloaded',
templateId: entry.templateId,
part: segment.part,
taskId: segment.taskId,
}));
}
}
if (!entry.finalVideoUrl && entry.segments.every(segment => segment.status === 'succeeded' && segment.filePath)) {
entry.finalVideoUrl = await composeTemplate(entry);
await updateFinalTask(entry);
changed = true;
entryChanged = true;
console.log(JSON.stringify({
action: 'composed',
templateId: entry.templateId,
videoUrl: entry.finalVideoUrl,
}));
}
if (entryChanged && !entry.finalVideoUrl) {
await updateSessionProgress(entry);
}
}
tracker.updatedAt = Date.now();
if (changed) await saveTracker(tracker);
if (allDone(tracker) || opts.once) break;
if (Date.now() - started > maxWaitMs) fail(`Timed out waiting for Seedance tasks after ${Math.round(maxWaitMs / 60000)} minutes`);
await sleep(pollMs);
}
}
async function getTask(taskId) {
const res = await fetch(`${apiBase}/contents/generations/tasks/${encodeURIComponent(taskId)}`, {
headers: { authorization: `Bearer ${apiKey}` },
});
const rawText = await res.text();
if (!res.ok) throw new Error(`Seedance status ${res.status}: ${rawText}`);
const raw = JSON.parse(rawText);
return {
status: normalizeStatus(raw.status),
videoUrl: raw.video_url || raw.output?.video_url || raw.output?.url || raw.content?.video_url || raw.content?.url,
raw,
};
}
async function downloadVideo(sessionIdValue, templateId, part, taskId, url) {
if (!url) throw new Error(`missing video URL for ${templateId} part ${part}`);
const res = await fetch(url);
if (!res.ok) throw new Error(`download ${res.status} for ${templateId} part ${part}`);
const type = res.headers.get('content-type') || 'video/mp4';
const ext = type.includes('webm') ? 'webm' : 'mp4';
const filename = `${safe(sessionIdValue)}_${safe(templateId)}_part${part}_${safe(taskId)}.${ext}`;
const filePath = path.join(segmentDir, filename);
await streamToFile(res.body, filePath);
return filePath;
}
async function composeTemplate(entry) {
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
.sort((a, b) => a.part - b.part)
.map(segment => `file '${String(segment.filePath).replace(/'/g, "'\\''")}'`)
.join('\n');
await fs.writeFile(listPath, `${list}\n`);
let result = spawnSync('ffmpeg', ['-y', '-f', 'concat', '-safe', '0', '-i', listPath, '-c', 'copy', outputPath], { encoding: 'utf8' });
if (result.status !== 0) {
result = spawnSync('ffmpeg', ['-y', '-f', 'concat', '-safe', '0', '-i', listPath, '-c:v', 'libx264', '-c:a', 'aac', '-movflags', '+faststart', outputPath], { encoding: 'utf8' });
}
if (result.status !== 0) {
throw new Error(`ffmpeg failed for ${entry.templateId}: ${result.stderr || result.stdout}`);
}
return `/api/video-file/${outputName}`;
}
async function updateFinalTask(entry) {
const session = await readSession();
const index = (session.videoTasks || []).findIndex(task => task.templateId === entry.templateId);
const now = Date.now();
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,
status: 'succeeded',
videoUrl: entry.finalVideoUrl,
duration: TARGET_SECONDS,
updatedAt: now,
raw: {
...(typeof current.raw === 'object' && current.raw ? current.raw : {}),
seedanceSegmented: {
runLabel,
targetSeconds: TARGET_SECONDS,
segmentSeconds: SEGMENT_SECONDS,
composedAt: now,
segments: entry.segments.map(segment => ({
part: segment.part,
taskId: segment.taskId,
status: segment.status,
remoteVideoUrl: segment.remoteVideoUrl,
})),
},
},
};
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`);
}
async function updateAllProgress(tracker) {
for (const entry of Object.values(tracker.templates)) {
await updateSessionProgress(entry);
}
}
async function updateSessionProgress(entry) {
const session = await readSession();
const index = (session.videoTasks || []).findIndex(task => task.templateId === entry.templateId);
const now = Date.now();
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;
const nextTask = {
...current,
provider: 'seedance',
model,
taskId: firstTaskId,
status: taskStatus(entry),
videoUrl: entry.finalVideoUrl,
duration: TARGET_SECONDS,
updatedAt: now,
raw: {
...(typeof current.raw === 'object' && current.raw ? current.raw : {}),
seedanceSegmented: {
runLabel,
targetSeconds: TARGET_SECONDS,
segmentSeconds: SEGMENT_SECONDS,
updatedAt: now,
segments: entry.segments.map(segment => ({
part: segment.part,
taskId: segment.taskId,
status: segment.status,
remoteVideoUrl: segment.remoteVideoUrl,
})),
},
},
};
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`);
}
function taskStatus(entry) {
if (entry.finalVideoUrl) return 'succeeded';
if (entry.segments.some(segment => segment.status === 'failed')) return 'failed';
if (entry.segments.some(segment => segment.status === 'processing')) return 'processing';
if (entry.segments.some(segment => segment.taskId)) return 'submitted';
return 'processing';
}
function segmentPrompt(entry, part) {
const fullScreenFaceConstraints = entry.templateId === 'youni_family_meey_screen_showcase'
? [
'本条视频必须把“脸部是一整块完整显示屏”作为最高优先级,覆盖参考图中的橙色弧形 visor 造型;不要生成窄条 visor、护目镜、眼罩、头盔眉檐压住屏幕、外壳横条遮挡、口罩或任何挡住屏幕的结构。',
'每个正面或近景镜头都要能看见完整矩形或大圆角矩形屏幕边界,屏幕面积占脸部主体,大屏里显示表情包或演唱会画面;屏幕外壳只能作为薄边框。',
'可以保留白色圆润机甲身体、MEEY 顶部识别条、胸前 M 徽章、斜挎能量肩带和 40cm+ 陪伴机器人比例,但不要保留旧参考图的橙色眯眼 visor 脸。',
]
: PRODUCT_CONTEXT.constraints;
return [
entry.prompt.trim(),
'',
`这是 Seedance 分段生成的第 ${part}/${SEGMENT_COUNT} 段,每段 ${SEGMENT_SECONDS} 秒,最终会拼成 ${TARGET_SECONDS} 秒完整视频。`,
partCue(part, entry.templateId),
`硬性尺寸约束:${PRODUCT_SIZE_TEXT}`,
`必须明显是 ${PRODUCT_SIZE_LABEL} 以上的实体产品:${PRODUCT_CONTEXT.scaleProof}`,
'参考图里的中文和数字只用于理解尺寸比例;成片画面中不要生成任何数字、厘米文字、箭头尺寸标注或文字海报,避免出现错误读数。',
...fullScreenFaceConstraints,
].filter(Boolean).join('\n');
}
function partCue(part, templateId) {
if (templateId === 'youni_family_meey_screen_showcase') {
if (part === 1) return '第 1 段:建立“有你家族”家庭陪伴场景,机器人正面亮屏登场,脸部必须是无遮挡大显示屏,先展示微笑、爱心、欢迎等表情包。';
if (part === 2) return '第 2 段:重点展示完整屏幕脸播放演唱会视频,屏幕里有舞台灯光、歌手剪影、观众光棒或音乐可视化;屏幕边界必须完整可见。';
if (part === SEGMENT_COUNT) return '最后一段:用户语音点播,机器人用完整屏幕切换表情包并继续播放音乐会画面,最后以无遮挡大屏微笑正面收束。';
return `${part} 段:保持完整大屏脸和有你家族陪伴主题,增加表情包、音乐播放或人机互动,不要回到窄条 visor。`;
}
if (PRODUCT_CONTEXT.kind === 'nuonuo') {
if (part === 1) return '第 1 段:建立家庭使用场景,糯糯猪从沙发、床边或儿童房角落自主行动到用户身边,展示 45cm 大尺寸毛绒体量。';
if (part === 2) return '第 2 段:重点展示语音互动和触摸反馈,猪鼻子、耳朵、眼睛、尾巴分别被触发并给出灯效、声音或动作回应。';
if (part === SEGMENT_COUNT) return '最后一段:进入亲子陪伴或睡前故事场景,糯糯猪被拥抱后仍有轻微眨眼、耳朵摆动和尾巴反馈,温暖收束。';
return `${part} 段:保持同一只糯糯猪和同一尺寸,增加真实家庭人机互动、语音问答或柔软触感细节。`;
}
if (PRODUCT_CONTEXT.kind === 'zodiac-fantasy') {
if (part === 1) return '第 1 段:建立系列展台和亥猪主角,亥猪屏幕脸点亮,先展示整体体量、粉色幻装、胸前徽章和斜挎肩带;额头猪鼻子可以不出现。';
if (part === 2) return '第 2 段:继续同一只亥猪,展示显示屏脸切换喜怒哀乐表情包、语音互动回应和播放视频画面,其它生肖只做背景。';
if (part === SEGMENT_COUNT) return '最后一段:背景十二生肖阵列亮屏,亥猪回到 C 位,屏幕脸用温暖表情收束,明确 50cm+ 具身 AI 陪伴机器人尺度。';
return `${part} 段:保持亥猪主角身份和 50cm+ 尺度,增加人机互动、局部特写或系列阵列变化,不改变基础设计。`;
}
if (PRODUCT_CONTEXT.kind === 'hai-pig-meey') {
if (part === 1) return '第 1 段:建立亥猪 MEEY 在客厅或展台中的真实比例展示屏幕脸、MEEY 顶部识别条、胸前徽章和斜挎肩带。';
if (part === 2) return '第 2 段:重点拍面部显示器,连续切换喜怒哀乐表情包,并展示屏幕播放视频与用户语音互动。';
if (part === SEGMENT_COUNT) return '最后一段:成人手部或儿童与机器人互动,机器人用屏幕表情、灯效和轻微转头回应,完整产品正面收束。';
return `${part} 段:保持同一台亥猪 MEEY 和 40cm+ 尺寸,增加陪伴交互、局部特写或多媒体播放场景。`;
}
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 productContextForSession(value) {
if (value.includes('s_mps3u047') || value.includes('nuonuo')) {
return {
kind: 'nuonuo',
sizeLabel: '45cm',
sizeText: '主角始终是“有你家族 · 糯糯猪”约 45cm 大尺寸智能陪伴毛绒玩具,必须明显是可拥抱的家庭陪伴产品体量;可用儿童/成人手部、沙发、床、地毯、抱枕或包装盒衬托比例,不能像掌心小玩偶、钥匙扣、小挂件或桌面迷你摆件。',
scaleProof: '可用儿童/成人手部、沙发、床、地毯、抱枕或包装盒证明约 45cm 可拥抱体量;不能像掌心小玩偶、桌面迷你摆件、挂件或钥匙扣。',
constraints: [
'必须保留浅粉色长绒毛圆胖坐姿小猪形象、黑亮圆眼睛、粉色立体猪鼻、下垂猪耳朵、短卷尾巴、短小四肢、金色挂绳项圈和爱心吊牌。',
'必须体现智能互动能力:自主轻微移动、语音问答、触摸传感反馈、眼部灯效、鼻尖呼吸光、耳朵轻摆、尾巴卷动或抖动。反馈可以温柔夸张,但不能变成硬质机甲机器人。',
'外观可以暗示内置可拆卸智能机芯和安全电池仓,但主体必须是亲肤长绒毛绒玩具。禁止第三方 IP、水印、文字广告、真实品牌标识和错误尺寸标注。',
],
};
}
if (value.includes('zodiac_fantasy')) {
return {
kind: 'zodiac-fantasy',
sizeLabel: '50cm+',
sizeText: '主角始终是“有你家族 · 生肖幻装系列”中的亥猪 50cm+ 具身 AI 智能陪伴机器人,必须明显是家庭空间级真实产品体量;可用成人手部、沙发、展台、包装盒或其他生肖机器人衬托比例,不能像桌面小摆件、掌心玩偶、挂件或盲盒。',
scaleProof: '可用成人双手、包装盒、展台、沙发或其他生肖机器人证明家庭空间级体量;不能像掌心小玩偶、桌面迷你摆件、挂件或盲盒。',
constraints: [
'必须保留亥猪作为系列代表:粉色猪系毛绒幻装、电子显示屏/情绪 visor 脸、M 家族徽章、斜挎能量肩带、圆润机甲基型和 50cm+ 陪伴机器人比例。',
'额头上的猪鼻子在本条互动展示中可以不显示;不要把猪鼻子做成突出的额头装饰。面部重点是显示屏脸、表情包、喜怒哀乐和视频播放。',
'其它生肖幻装机器人只能作为背景阵列、远景陈列或辅助队列,不能抢走亥猪主角。禁止真实动物身体、四足化、攻击性重装、武器、水印和错误文字。',
],
};
}
return {
kind: 'hai-pig-meey',
sizeLabel: '40cm+',
sizeText: '主角始终是“有你家族 · 亥猪 MEEY 机甲陪伴机器人”40cm+ AI 陪伴机甲摆件,高度必须超过 40cm正面宽约 28cm侧面深约 22cm。',
scaleProof: '可用成人双手、包装盒、展台、桌面或家居物件证明比例;不能像掌心小玩偶、桌面迷你摆件、挂件或钥匙扣。',
constraints: [
'必须保留白色圆润头盔、橙色弧形显示屏脸/visor、深灰面部底层、头顶 MEEY 竖条、胸前 M 徽章、斜挎黑色能量肩带、灰橙功能扣、侧面圆形模块、短胖站立比例。',
'面部显示器必须可以切换喜怒哀乐、撒娇、确认、睡眠等表情包,并可以播放视频或动画片段;人机互动要通过屏幕表情、灯效、语音回应和轻微身体动作体现。',
'外部可呈现亲肤短绒、软壳或软硅胶复合触感,但不能改变基础机甲设计。禁止变成真实猪、毛绒动物、四足身体、武器和攻击性重装机甲。',
],
};
}
function normalizeStatus(status) {
if (status === 'succeeded' || status === 'success' || status === 'completed') return 'succeeded';
if (status === 'failed' || status === 'error') return 'failed';
if (status === 'processing' || status === 'running' || status === 'in_progress') return 'processing';
return 'submitted';
}
function publicUrl(url) {
if (/^https?:\/\//i.test(url)) return url;
return new URL(url, publicAppUrl).toString();
}
function findAnchor(session) {
const packs = session.packs || [];
const preferred = [
['marketing', 'mkt_white_front'],
['patent', 'patent_front'],
];
for (const [kind, templateId] of preferred) {
const asset = packs.find(pack => pack.kind === kind)?.assets?.find(item => item.templateId === templateId);
if (asset?.url) return asset.url;
}
const selected = (session.images || []).find(image => image.status === 'selected') || session.images?.[0];
if (selected?.url) return selected.url;
fail('No anchor image found');
}
function findVideoReferenceUrls(session, fallbackUrl) {
if (session.id?.includes('zodiac_fantasy') || sessionId.includes('zodiac_fantasy')) {
return [
findAssetUrl(session, 'zodiac_fantasy_pig_sku_card'),
findAssetUrl(session, 'patent_pig_six_view'),
fallbackUrl,
].filter(Boolean).filter((url, index, urls) => urls.indexOf(url) === index);
}
const preferredTemplateIds = [
'zodiac_fantasy_pig_sku_card',
'zodiac_fantasy_collection_kv',
'zodiac_fantasy_retail_grid',
'zodiac_fantasy_patent_overview',
'zodiac_fantasy_female_lifestyle',
'patent_pig_six_view',
'zodiac_hero_lineup',
'zodiac_armor_grid',
'zodiac_material_palette',
'zodiac_module_variants',
'prod_dimension_overall',
'mkt_white_front',
'mkt_white_back',
];
const urls = [];
for (const templateId of preferredTemplateIds) {
const url = findAssetUrl(session, templateId);
if (url) urls.push(url);
}
if (fallbackUrl) urls.push(fallbackUrl);
return [...new Set(urls)];
}
function findAssetUrl(session, templateId) {
for (const pack of session.packs || []) {
const asset = pack.assets?.find(item => item.templateId === templateId);
if (asset?.url) return asset.url;
}
return undefined;
}
async function readSession() {
return JSON.parse(await fs.readFile(sessionPath, 'utf8'));
}
async function readTracker() {
try {
return JSON.parse(await fs.readFile(trackerPath, 'utf8'));
} catch {
return null;
}
}
async function saveTracker(tracker) {
await fs.writeFile(trackerPath, `${JSON.stringify(tracker, null, 2)}\n`);
}
function allDone(tracker) {
return Object.values(tracker.templates).every(entry => entry.finalVideoUrl);
}
async function streamToFile(body, filePath) {
if (!body) throw new Error('response body missing');
await fs.mkdir(path.dirname(filePath), { recursive: true });
await new Promise((resolve, reject) => {
const stream = createWriteStream(filePath);
Readable.fromWeb(body).pipe(stream);
stream.on('finish', resolve);
stream.on('error', reject);
});
}
function printSummary(tracker) {
const summary = Object.values(tracker.templates).map(entry => ({
templateId: entry.templateId,
finalVideoUrl: entry.finalVideoUrl,
segments: entry.segments.map(segment => ({
part: segment.part,
taskId: segment.taskId,
status: segment.status,
hasFile: Boolean(segment.filePath),
})),
}));
console.log(JSON.stringify(summary, null, 2));
}
function safe(value) {
return String(value).replace(/[^a-zA-Z0-9_.-]+/g, '_').slice(0, 120);
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function fail(message) {
console.error(message);
process.exit(1);
}