747 lines
39 KiB
JavaScript
747 lines
39 KiB
JavaScript
#!/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 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 徽章、斜挎能量肩带和灰橙功能扣必须稳定一致。面部显示器要清楚呈现喜、怒、哀、乐、撒娇、确认、睡眠等表情包切换,并展示用户靠近说话、机器人屏幕文字/表情回应、播放家庭短视频或动画片段、用灯效和轻微身体动作反馈的场景。镜头结构:开场桌面/客厅真实比例亮相;中段重点拍屏幕脸表情包和视频播放;后段成人手部或儿童在旁边互动,机器人转头/屏幕回应;最后产品完整正面收束。不要变成真实猪、毛绒动物或小手办。风格温暖科技、适合新品展示,原创电子节奏,不使用真实受版权保护歌曲。`,
|
||
},
|
||
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 = 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.prompt, 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(prompt, part) {
|
||
return [
|
||
prompt.trim(),
|
||
'',
|
||
`这是 Seedance 分段生成的第 ${part}/${SEGMENT_COUNT} 段,每段 ${SEGMENT_SECONDS} 秒,最终会拼成 ${TARGET_SECONDS} 秒完整视频。`,
|
||
partCue(part),
|
||
`硬性尺寸约束:${PRODUCT_SIZE_TEXT}`,
|
||
`必须明显是 ${PRODUCT_SIZE_LABEL} 以上的实体产品:${PRODUCT_CONTEXT.scaleProof}`,
|
||
'参考图里的中文和数字只用于理解尺寸比例;成片画面中不要生成任何数字、厘米文字、箭头尺寸标注或文字海报,避免出现错误读数。',
|
||
...PRODUCT_CONTEXT.constraints,
|
||
].filter(Boolean).join('\n');
|
||
}
|
||
|
||
function partCue(part) {
|
||
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);
|
||
}
|