feat: add Hai Pig zodiac video workflow
This commit is contained in:
@@ -6,9 +6,15 @@ 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 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',
|
||||
@@ -16,19 +22,7 @@ const TEMPLATE_IDS = [
|
||||
'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 runLabel = safe(args['run-label'] || `seedance${TARGET_SECONDS}`);
|
||||
const freshRun = Boolean(args.fresh);
|
||||
const selectedTemplateIds = String(args.templates || '')
|
||||
.split(',')
|
||||
@@ -36,6 +30,51 @@ const selectedTemplateIds = String(args.templates || '')
|
||||
.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,
|
||||
@@ -97,6 +136,12 @@ function parseArgs(argv) {
|
||||
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');
|
||||
@@ -132,8 +177,9 @@ async function ensureTracker() {
|
||||
|
||||
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);
|
||||
const task = (session.videoTasks || []).find(item => item.templateId === templateId) || buildDefaultVideoTask(session, templateId, character, anchor);
|
||||
if (!task) continue;
|
||||
const entry = tracker.templates[templateId] || {
|
||||
templateId,
|
||||
@@ -171,6 +217,41 @@ async function ensureTracker() {
|
||||
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) {
|
||||
@@ -306,7 +387,7 @@ async function downloadVideo(sessionIdValue, templateId, part, taskId, url) {
|
||||
}
|
||||
|
||||
async function composeTemplate(entry) {
|
||||
const outputName = `${safe(sessionId)}_${safe(entry.templateId)}_${runLabel}_60s.mp4`;
|
||||
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
|
||||
@@ -328,10 +409,12 @@ async function composeTemplate(entry) {
|
||||
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] = {
|
||||
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,
|
||||
@@ -341,7 +424,7 @@ async function updateFinalTask(entry) {
|
||||
updatedAt: now,
|
||||
raw: {
|
||||
...(typeof current.raw === 'object' && current.raw ? current.raw : {}),
|
||||
seedance60: {
|
||||
seedanceSegmented: {
|
||||
runLabel,
|
||||
targetSeconds: TARGET_SECONDS,
|
||||
segmentSeconds: SEGMENT_SECONDS,
|
||||
@@ -355,6 +438,9 @@ async function updateFinalTask(entry) {
|
||||
},
|
||||
},
|
||||
};
|
||||
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`);
|
||||
}
|
||||
|
||||
@@ -367,11 +453,13 @@ async function updateAllProgress(tracker) {
|
||||
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 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;
|
||||
session.videoTasks[index] = {
|
||||
const nextTask = {
|
||||
...current,
|
||||
provider: 'seedance',
|
||||
model,
|
||||
@@ -382,7 +470,7 @@ async function updateSessionProgress(entry) {
|
||||
updatedAt: now,
|
||||
raw: {
|
||||
...(typeof current.raw === 'object' && current.raw ? current.raw : {}),
|
||||
seedance60: {
|
||||
seedanceSegmented: {
|
||||
runLabel,
|
||||
targetSeconds: TARGET_SECONDS,
|
||||
segmentSeconds: SEGMENT_SECONDS,
|
||||
@@ -396,6 +484,9 @@ async function updateSessionProgress(entry) {
|
||||
},
|
||||
},
|
||||
};
|
||||
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`);
|
||||
}
|
||||
|
||||
@@ -412,14 +503,27 @@ function segmentPrompt(prompt, part) {
|
||||
prompt.trim(),
|
||||
'',
|
||||
`这是 Seedance 分段生成的第 ${part}/${SEGMENT_COUNT} 段,每段 ${SEGMENT_SECONDS} 秒,最终会拼成 ${TARGET_SECONDS} 秒完整视频。`,
|
||||
PART_CUES[part - 1],
|
||||
'硬性尺寸约束:主角始终是“有你家族 · 糯糯猪”智能陪伴毛绒娃娃,高度约 45cm,正面宽约 32cm,侧深约 28cm,背面宽约 33cm。',
|
||||
'必须明显是 40cm 以上的大尺寸抱抱玩偶:优先用成人双手、前臂、胸前怀抱、包装盒、椅子或床品证明比例;不能像掌心小玩偶、桌面摆件、挂件或钥匙扣。',
|
||||
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';
|
||||
@@ -449,6 +553,10 @@ function findAnchor(session) {
|
||||
|
||||
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',
|
||||
|
||||
Reference in New Issue
Block a user