#!/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 SEGMENT_SECONDS = 15; const SEGMENT_COUNT = 4; const TARGET_SECONDS = SEGMENT_SECONDS * SEGMENT_COUNT; const TEMPLATE_IDS = [ 'video_turntable', 'video_unboxing', 'video_touch_detail', '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 freshRun = Boolean(args.fresh); const selectedTemplateIds = String(args.templates || '') .split(',') .map(item => item.trim()) .filter(Boolean); const activeTemplateIds = selectedTemplateIds.length ? selectedTemplateIds : TEMPLATE_IDS; 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 = 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 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); for (const templateId of activeTemplateIds) { const task = (session.videoTasks || []).find(item => item.templateId === templateId); 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; } 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}_60s.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); if (index < 0) throw new Error(`session video task missing: ${entry.templateId}`); const now = Date.now(); const current = session.videoTasks[index]; session.videoTasks[index] = { ...current, provider: 'seedance', model, status: 'succeeded', videoUrl: entry.finalVideoUrl, duration: TARGET_SECONDS, updatedAt: now, raw: { ...(typeof current.raw === 'object' && current.raw ? current.raw : {}), seedance60: { 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, })), }, }, }; 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); if (index < 0) return; const now = Date.now(); const current = session.videoTasks[index]; const firstTaskId = entry.segments.find(segment => segment.taskId)?.taskId; session.videoTasks[index] = { ...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 : {}), seedance60: { 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, })), }, }, }; 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} 秒完整视频。`, PART_CUES[part - 1], '硬性尺寸约束:主角始终是“有你家族 · 糯糯猪”智能陪伴毛绒娃娃,高度约 45cm,正面宽约 32cm,侧深约 28cm,背面宽约 33cm。', '必须明显是 40cm 以上的大尺寸抱抱玩偶:优先用成人双手、前臂、胸前怀抱、包装盒、椅子或床品证明比例;不能像掌心小玩偶、桌面摆件、挂件或钥匙扣。', '参考图里的中文和数字只用于理解尺寸比例;成片画面中不要生成任何数字、厘米文字、箭头尺寸标注或文字海报,避免出现错误读数。', '保持浅粉长绒、圆胖坐姿、黑亮眼睛、粉色猪鼻、下垂耳朵、金色挂绳和爱心吊牌,不要变成小挂件、桌面小摆件或其它动物。', ].join('\n'); } 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) { const preferredTemplateIds = [ '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); }