#!/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 [--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); }