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

530 lines
18 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 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 <sessionId> [--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);
}