638 lines
26 KiB
JavaScript
638 lines
26 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 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 徽章、斜挎黑色能量肩带、灰橙扣件和短胖站姿全部保留。每个生肖只通过可换装甲区域、色彩符号和表面短绒/软壳材料区分,禁止真实动物器官、禁止重装战斗化。镜头要有节奏感、旋转台、局部特写和集合收束,音乐为原创韩流感电子鼓点,不使用真实歌曲。`,
|
||
},
|
||
};
|
||
|
||
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 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),
|
||
'硬性尺寸约束:主角始终是“有你家族 · 亥猪”40cm+ AI 陪伴机甲摆件,高度必须超过 40cm,正面宽约 28cm,侧面深约 22cm。',
|
||
'必须明显是 40cm 以上的实体产品:可用成人双手、包装盒、展台、桌面或家居物件证明比例;不能像掌心小玩偶、桌面迷你摆件、挂件或钥匙扣。',
|
||
'参考图里的中文和数字只用于理解尺寸比例;成片画面中不要生成任何数字、厘米文字、箭头尺寸标注或文字海报,避免出现错误读数。',
|
||
'必须保留白色圆润头盔、橙色弧形 visor 情绪屏、深灰面部底层、头顶 MEEY 竖条、胸前 M 徽章、斜挎黑色能量肩带、灰橙功能扣、侧面圆形模块、短胖站立比例。',
|
||
'外部可呈现亲肤短绒、软壳或软硅胶复合触感,但不能改变基础机甲设计。禁止猪鼻子、猪尾巴、写实猪耳、猪蹄、四足身体、其它动物主体、武器和攻击性重装机甲。',
|
||
].join('\n');
|
||
}
|
||
|
||
function partCue(part) {
|
||
if (SEGMENT_COUNT <= 2) {
|
||
return part === 1
|
||
? '第 1 段:建立系列世界观和主角登场,亥猪先出现,镜头给足正面、肩带、徽章、情绪屏和 40cm+ 体量。'
|
||
: '第 2 段:进入集合高潮和收束,展示装甲变化、局部细节、全体阵列或旋转台,最后回到亥猪主角。';
|
||
}
|
||
if (part === 1) return '第 1 段:建立镜头,先稳定展示亥猪整体体量、正面轮廓、橙色 visor、胸前徽章和斜挎肩带。';
|
||
if (part === 2) return '第 2 段:继续同一只产品,推进到侧面、背面、顶部 MEEY 识别条、侧面圆形模块和短绒/软壳材质细节。';
|
||
if (part === SEGMENT_COUNT) return '最后一段:回到完整产品展示收束,补充包装、材料、生产或集合场景,明确 40cm+ 成品尺度。';
|
||
return `第 ${part} 段:保持同一角色和同一尺寸,增加使用场景、局部特写、触感互动或系列装甲转换,不改变基础设计。`;
|
||
}
|
||
|
||
function normalizeStatus(status) {
|
||
if (status === 'succeeded' || status === 'success' || status === 'completed') return 'succeeded';
|
||
if (status === 'failed' || status === 'error') return 'failed';
|
||
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 = [
|
||
'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);
|
||
}
|