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

638 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
import { createWriteStream, readFileSync } from 'node:fs';
import fs from 'node:fs/promises';
import path from 'node:path';
import { Readable } from 'node:stream';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const args = parseArgs(process.argv.slice(2));
const command = args._[0] || 'run';
const sessionId = args.session || args._[1];
if (!sessionId) fail('Usage: node scripts/seedance-60s-compose.mjs run --session <sessionId> [--target-seconds 45] [--env deploy/.env.production]');
const SEGMENT_SECONDS = clampNumber(args['segment-seconds'], 15, 3, 15);
const TARGET_SECONDS = clampNumber(args['target-seconds'] || args.seconds, 45, SEGMENT_SECONDS, 180);
const SEGMENT_COUNT = Math.ceil(TARGET_SECONDS / SEGMENT_SECONDS);
const 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);
}