feat: add Seedance segmented video workflow
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
],
|
],
|
||||||
|
|||||||
14
RULES.md
14
RULES.md
@@ -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 Sora;Seedance 只作为可选回退 provider
|
- 视频生成默认使用 Seedance;OpenAI 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. 侧栏保留历史会话,点击切换
|
||||||
|
|
||||||
## 后续路线
|
## 后续路线
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
529
scripts/seedance-60s-compose.mjs
Normal file
529
scripts/seedance-60s-compose.mjs
Normal 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);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user