feat: add Seedance segmented video workflow

This commit is contained in:
2026-05-30 20:06:18 +08:00
parent 2270f4de09
commit a0c8f5e6d0
7 changed files with 558 additions and 21 deletions

View File

@@ -4,13 +4,14 @@ GPT_TEXT_MODEL=gpt-5.5
GPT_IMAGE_MODEL=gpt-image-2 GPT_IMAGE_MODEL=gpt-image-2
GPT_API_BASE=https://api.openai.com/v1 GPT_API_BASE=https://api.openai.com/v1
# 视频生成默认走 OpenAI Sora如需回退 Seedance,设置 VIDEO_PROVIDER=seedance # 视频生成默认走 Seedance如需回退 OpenAI Sora,设置 VIDEO_PROVIDER=openai_sora
VIDEO_PROVIDER=openai_sora VIDEO_PROVIDER=seedance
OPENAI_VIDEO_MODEL=sora-2-pro OPENAI_VIDEO_MODEL=sora-2-pro
# OPENAI_VIDEO_MIN_SECONDS 只用于 OpenAI Sora当前 Seedance 模型单任务按 15 秒上限提交。
OPENAI_VIDEO_MIN_SECONDS=60 OPENAI_VIDEO_MIN_SECONDS=60
OPENAI_VIDEO_QUALITY=high OPENAI_VIDEO_QUALITY=high
# Seedance 作为可选回退 provider。未配置 Key 且 VIDEO_PROVIDER=seedance 时 /api/video/generate 返回 503。 # Seedance 是当前默认视频 provider。未配置 Key 且 VIDEO_PROVIDER=seedance 时 /api/video/generate 返回 503。
SEEDANCE_API_KEY= SEEDANCE_API_KEY=
SEEDANCE_MODEL=doubao-seedance-2-0-260128 SEEDANCE_MODEL=doubao-seedance-2-0-260128
SEEDANCE_API_BASE=https://ark.cn-beijing.volces.com/api/v3 SEEDANCE_API_BASE=https://ark.cn-beijing.volces.com/api/v3

View File

@@ -4,12 +4,12 @@
{ {
"env" : "OPENAI_API_KEY", "env" : "OPENAI_API_KEY",
"name" : "OPENAI_API_KEY", "name" : "OPENAI_API_KEY",
"note" : "GPT 文本\/结构化\/图片生成 + OpenAI Sora 视频;没填则图片 mock、视频不可用" "note" : "GPT 文本\/结构化\/图片生成;没填则图片 mock"
}, },
{ {
"env" : "SEEDANCE_API_KEY", "env" : "SEEDANCE_API_KEY",
"name" : "SEEDANCE_API_KEY", "name" : "SEEDANCE_API_KEY",
"note" : "Seedance 视频生成;没填则视频接口不可用" "note" : "Seedance 视频生成;当前视频默认 provider没填则视频接口不可用"
}, },
{ {
"env" : "WEB_AUTH_USERNAME\/WEB_AUTH_PASSWORD\/WEB_AUTH_SESSION_SECRET", "env" : "WEB_AUTH_USERNAME\/WEB_AUTH_PASSWORD\/WEB_AUTH_SESSION_SECRET",
@@ -37,7 +37,7 @@
"username" : "kangwan" "username" : "kangwan"
}, },
"stack" : [ "stack" : [
"Next.js + GPT + OpenAI Sora", "Next.js + GPT + Seedance 视频",
"Docker Compose local\/prod parity", "Docker Compose local\/prod parity",
"Coolify Traefik" "Coolify Traefik"
], ],

View File

@@ -9,8 +9,8 @@
## 部署事实 ## 部署事实
- 平台:个人 VPS `76.13.31.179`Docker Compose接入现有 Coolify Traefik - 平台:个人 VPS `76.13.31.179`Docker Compose接入现有 Coolify Traefik
- 发布状态VPS 生产已发布,仅个人使用 - 发布状态VPS 生产已发布,仅个人使用
- 最近生产部署2026-05-30视频 provider 改为默认 OpenAI Sora`VIDEO_PROVIDER=openai_sora`Seedance 仅作为可选回退;视频模板目标时长统一不少于 60 秒,并通过 OpenAI `/videos/extensions` 链路补足长视频;对应代码提交 `9c41caf` - 最近生产部署2026-05-30视频 provider 改为默认 Seedance`VIDEO_PROVIDER=seedance`OpenAI Sora 仅作为可选回退;实测 Ark / Seedance `doubao-seedance-2-0` R2V 不接受 `duration=60`,当前 Seedance 单任务按 15 秒提交。若仍需 60 秒成片,需要 4 段 15 秒后拼接,或回退 OpenAI Sora 的延展链路。
- 最近生产数据同步2026-05-30`有你家族 · 糯糯猪` session `s_mps3u047_48e383` 已同步到 VPS `data/`,包含专利包、配件包、生产打样包、宣发包共 64 张图片Seedance 生产环境 Key 已换新但仍保持回退 provider。 - 最近生产数据同步2026-05-30`有你家族 · 糯糯猪` session `s_mps3u047_48e383` 已同步到 VPS `data/`,包含专利包、配件包、生产打样包、宣发包共 64 张图片Seedance 生产环境 Key 已换新并作为当前默认视频 provider。
- 服务名 / 容器名:`ai-toy-patent-workflow` - 服务名 / 容器名:`ai-toy-patent-workflow`
- 服务器路径:`/opt/ai-toy-patent-workflow` - 服务器路径:`/opt/ai-toy-patent-workflow`
- 主站 / 前端https://ai-toy.kang-kang.com - 主站 / 前端https://ai-toy.kang-kang.com
@@ -43,11 +43,11 @@
- `GPT_TEXT_MODEL` — 默认 `gpt-5.5`,用于角色设定等结构化输出 - `GPT_TEXT_MODEL` — 默认 `gpt-5.5`,用于角色设定等结构化输出
- `GPT_IMAGE_MODEL` — 默认 `gpt-image-2`,用于意向图和三类素材包图片生成 - `GPT_IMAGE_MODEL` — 默认 `gpt-image-2`,用于意向图和三类素材包图片生成
- `GPT_API_BASE` — 默认 `https://api.openai.com/v1` - `GPT_API_BASE` — 默认 `https://api.openai.com/v1`
- `VIDEO_PROVIDER` — 默认 `openai_sora`;需要回退时可设为 `seedance` - `VIDEO_PROVIDER` — 默认 `seedance`;需要回退 OpenAI Sora 时可设为 `openai_sora`
- `OPENAI_VIDEO_MODEL` — 默认 `sora-2-pro`,用于 OpenAI 视频生成 - `OPENAI_VIDEO_MODEL` — 默认 `sora-2-pro`,用于 OpenAI 视频生成
- `OPENAI_VIDEO_MIN_SECONDS` — 默认 `60`视频模板目标时长不得低于 60 秒 - `OPENAI_VIDEO_MIN_SECONDS` — 默认 `60`仅用于 OpenAI Sora 目标时长Seedance 当前单任务按模型可接受的 15 秒上限提交
- `OPENAI_VIDEO_QUALITY` — 默认生产建议 `high`,对应 OpenAI 允许的视频输出尺寸 - `OPENAI_VIDEO_QUALITY` — 默认生产建议 `high`,对应 OpenAI 允许的视频输出尺寸
- `SEEDANCE_API_KEY` — Seedance 视频生成 Key`VIDEO_PROVIDER=seedance`使用,未配置则视频接口返回 503 - `SEEDANCE_API_KEY` — Seedance 视频生成 Key当前默认视频 provider 使用,未配置则视频接口返回 503
- `SEEDANCE_MODEL` — 默认 `doubao-seedance-2-0-260128` - `SEEDANCE_MODEL` — 默认 `doubao-seedance-2-0-260128`
- `SEEDANCE_API_BASE` — 默认 `https://ark.cn-beijing.volces.com/api/v3` - `SEEDANCE_API_BASE` — 默认 `https://ark.cn-beijing.volces.com/api/v3`
- `PUBLIC_APP_URL` — 生产填公网入口,用于把 `/api/img/...` 补成 Seedance 可访问的绝对 URL - `PUBLIC_APP_URL` — 生产填公网入口,用于把 `/api/img/...` 补成 Seedance 可访问的绝对 URL
@@ -62,7 +62,7 @@
## 规则 ## 规则
- 全项目规则真源:`/Users/kangwan/Projects/code/20260317-rules-dashboard/RULES.md` - 全项目规则真源:`/Users/kangwan/Projects/code/20260317-rules-dashboard/RULES.md`
- 文本/结构化/图片生成统一使用 GPT 最高规格配置 - 文本/结构化/图片生成统一使用 GPT 最高规格配置
- 视频生成默认使用 OpenAI SoraSeedance 只作为可选回退 provider - 视频生成默认使用 SeedanceOpenAI Sora 只作为可选回退 provider;当前 Seedance 模型不支持单次 60 秒直出
- 不允许编造不存在的部署域名、账号、密码 - 不允许编造不存在的部署域名、账号、密码
## 图像链路事实 ## 图像链路事实
@@ -100,7 +100,7 @@
5. 锁定角色设定 `CharacterSpec` 5. 锁定角色设定 `CharacterSpec`
6. 串行生成图片包:必须从专利包开始,顺序为 `专利包 -> 配件包 -> 生产打样包 -> 宣发包` 6. 串行生成图片包:必须从专利包开始,顺序为 `专利包 -> 配件包 -> 生产打样包 -> 宣发包`
7. 前一个图片包完整生成后,下一个图片包才解锁;不提供“一键全包”入口或全包 API 7. 前一个图片包完整生成后,下一个图片包才解锁;不提供“一键全包”入口或全包 API
8. 四个图片包完成后,才解锁文案模板和 OpenAI Sora 视频任务:旋转展示、开箱、触感细节、角色故事、工厂预览;每条视频目标时长不少于 60 秒 8. 四个图片包完成后,才解锁文案模板和视频任务:旋转展示、开箱、触感细节、角色故事、工厂预览;Seedance 单任务按 15 秒提交60 秒成片需要分段拼接或使用 OpenAI Sora 回退
9. 侧栏保留历史会话,点击切换 9. 侧栏保留历史会话,点击切换
## 后续路线 ## 后续路线

View File

@@ -4,13 +4,14 @@ GPT_TEXT_MODEL=gpt-5.5
GPT_IMAGE_MODEL=gpt-image-2 GPT_IMAGE_MODEL=gpt-image-2
GPT_API_BASE=https://api.openai.com/v1 GPT_API_BASE=https://api.openai.com/v1
# Video generation defaults to OpenAI Sora. Set VIDEO_PROVIDER=seedance only for fallback. # Video generation defaults to Seedance. Set VIDEO_PROVIDER=openai_sora only for fallback.
VIDEO_PROVIDER=openai_sora VIDEO_PROVIDER=seedance
OPENAI_VIDEO_MODEL=sora-2-pro OPENAI_VIDEO_MODEL=sora-2-pro
# OPENAI_VIDEO_MIN_SECONDS only applies to OpenAI Sora; current Seedance tasks are capped at 15s per request.
OPENAI_VIDEO_MIN_SECONDS=60 OPENAI_VIDEO_MIN_SECONDS=60
OPENAI_VIDEO_QUALITY=high OPENAI_VIDEO_QUALITY=high
# Optional Seedance fallback. # Seedance is the default video provider.
SEEDANCE_API_KEY= SEEDANCE_API_KEY=
SEEDANCE_MODEL=doubao-seedance-2-0-260128 SEEDANCE_MODEL=doubao-seedance-2-0-260128
SEEDANCE_API_BASE=https://ark.cn-beijing.volces.com/api/v3 SEEDANCE_API_BASE=https://ark.cn-beijing.volces.com/api/v3

View File

@@ -11,7 +11,8 @@
"docker:down": "docker compose down", "docker:down": "docker compose down",
"docker:logs": "docker compose logs -f web", "docker:logs": "docker compose logs -f web",
"resources:index": "node scripts/build-resource-index.mjs data", "resources:index": "node scripts/build-resource-index.mjs data",
"styles:previews": "node scripts/generate-style-previews.mjs" "styles:previews": "node scripts/generate-style-previews.mjs",
"videos:seedance60": "node scripts/seedance-60s-compose.mjs"
}, },
"dependencies": { "dependencies": {
"next": "^15.5.18", "next": "^15.5.18",

View File

@@ -0,0 +1,529 @@
#!/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);
}

View File

@@ -23,7 +23,7 @@ export function activeVideoProvider(): VideoProvider {
} }
function durationOrDefault(duration?: number): number { function durationOrDefault(duration?: number): number {
return Math.min(Math.max(duration ?? 6, 3), 15); return Math.min(Math.max(duration ?? 15, 3), 15);
} }
function openAITargetDuration(duration?: number): number { function openAITargetDuration(duration?: number): number {
@@ -113,8 +113,10 @@ function withProductVideoConstraints(prompt: string, targetDuration: number): st
return [ return [
prompt.trim(), prompt.trim(),
'', '',
'硬性约束:主角必须是“有你家族 · 糯糯猪”智能陪伴毛绒娃娃,整体成品高度约 45cm必须明显是 40cm 以上的大尺寸抱抱玩偶。', '硬性尺寸约束:主角必须是“有你家族 · 糯糯猪”智能陪伴毛绒娃娃,整体成品高度约 45cm正面宽约 32cm侧深约 28cm背面宽约 33cm。',
'保持浅粉毛绒、圆胖坐姿、黑亮眼睛、粉色猪鼻、下垂耳朵、金色挂绳和爱心吊牌;不要改成普通小挂件、钥匙扣或低于 40cm 的小公仔。', '必须明显是 40cm 以上的大尺寸抱抱玩偶:优先用成人双手、前臂、胸前怀抱、包装盒、椅子或床品证明比例;不能像掌心小玩偶、桌面摆件、挂件或钥匙扣。',
'如果参考图有中文或数字,只把它们当作尺寸比例依据;成片中不要生成任何数字、厘米文字、箭头尺寸标注或文字海报,避免出现错误读数。',
'保持浅粉长绒、圆胖坐姿、黑亮眼睛、粉色猪鼻、下垂耳朵、金色挂绳和爱心吊牌;不要改成普通小挂件、钥匙扣或低于 40cm 的小公仔。',
`目标视频总时长不少于 ${targetDuration} 秒;如果 API 需要分段或延展,保持同一角色、同一尺寸比例和连续镜头语言。`, `目标视频总时长不少于 ${targetDuration} 秒;如果 API 需要分段或延展,保持同一角色、同一尺寸比例和连续镜头语言。`,
].join('\n'); ].join('\n');
} }
@@ -154,7 +156,10 @@ export async function generateSeedanceVideo(opts: VideoGenerationRequest): Promi
if (!key) throw new Error('SEEDANCE_API_KEY missing'); if (!key) throw new Error('SEEDANCE_API_KEY missing');
if (!opts.prompt?.trim()) throw new Error('prompt required'); if (!opts.prompt?.trim()) throw new Error('prompt required');
const content = buildContent(opts); const content = buildContent({
...opts,
prompt: withProductVideoConstraints(opts.prompt, Math.max(opts.duration ?? 60, 60)),
});
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
model: SEEDANCE_MODEL, model: SEEDANCE_MODEL,
content, content,