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_API_BASE=https://api.openai.com/v1
|
||||
|
||||
# 视频生成默认走 OpenAI Sora;如需回退 Seedance,设置 VIDEO_PROVIDER=seedance。
|
||||
VIDEO_PROVIDER=openai_sora
|
||||
# 视频生成默认走 Seedance;如需回退 OpenAI Sora,设置 VIDEO_PROVIDER=openai_sora。
|
||||
VIDEO_PROVIDER=seedance
|
||||
OPENAI_VIDEO_MODEL=sora-2-pro
|
||||
# OPENAI_VIDEO_MIN_SECONDS 只用于 OpenAI Sora;当前 Seedance 模型单任务按 15 秒上限提交。
|
||||
OPENAI_VIDEO_MIN_SECONDS=60
|
||||
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_MODEL=doubao-seedance-2-0-260128
|
||||
SEEDANCE_API_BASE=https://ark.cn-beijing.volces.com/api/v3
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
{
|
||||
"env" : "OPENAI_API_KEY",
|
||||
"name" : "OPENAI_API_KEY",
|
||||
"note" : "GPT 文本\/结构化\/图片生成 + OpenAI Sora 视频;没填则图片 mock、视频不可用"
|
||||
"note" : "GPT 文本\/结构化\/图片生成;没填则图片 mock"
|
||||
},
|
||||
{
|
||||
"env" : "SEEDANCE_API_KEY",
|
||||
"name" : "SEEDANCE_API_KEY",
|
||||
"note" : "Seedance 视频生成;没填则视频接口不可用"
|
||||
"note" : "Seedance 视频生成;当前视频默认 provider,没填则视频接口不可用"
|
||||
},
|
||||
{
|
||||
"env" : "WEB_AUTH_USERNAME\/WEB_AUTH_PASSWORD\/WEB_AUTH_SESSION_SECRET",
|
||||
@@ -37,7 +37,7 @@
|
||||
"username" : "kangwan"
|
||||
},
|
||||
"stack" : [
|
||||
"Next.js + GPT + OpenAI Sora",
|
||||
"Next.js + GPT + Seedance 视频",
|
||||
"Docker Compose local\/prod parity",
|
||||
"Coolify Traefik"
|
||||
],
|
||||
|
||||
14
RULES.md
14
RULES.md
@@ -9,8 +9,8 @@
|
||||
## 部署事实
|
||||
- 平台:个人 VPS `76.13.31.179`,Docker Compose,接入现有 Coolify Traefik
|
||||
- 发布状态:VPS 生产已发布,仅个人使用
|
||||
- 最近生产部署:2026-05-30,视频 provider 改为默认 OpenAI Sora(`VIDEO_PROVIDER=openai_sora`),Seedance 仅作为可选回退;视频模板目标时长统一不少于 60 秒,并通过 OpenAI `/videos/extensions` 链路补足长视频;对应代码提交 `9c41caf`
|
||||
- 最近生产数据同步:2026-05-30,`有你家族 · 糯糯猪` session `s_mps3u047_48e383` 已同步到 VPS `data/`,包含专利包、配件包、生产打样包、宣发包共 64 张图片;Seedance 生产环境 Key 已换新但仍保持回退 provider。
|
||||
- 最近生产部署: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。
|
||||
- 服务名 / 容器名:`ai-toy-patent-workflow`
|
||||
- 服务器路径:`/opt/ai-toy-patent-workflow`
|
||||
- 主站 / 前端:https://ai-toy.kang-kang.com
|
||||
@@ -43,11 +43,11 @@
|
||||
- `GPT_TEXT_MODEL` — 默认 `gpt-5.5`,用于角色设定等结构化输出
|
||||
- `GPT_IMAGE_MODEL` — 默认 `gpt-image-2`,用于意向图和三类素材包图片生成
|
||||
- `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_MIN_SECONDS` — 默认 `60`;视频模板目标时长不得低于 60 秒
|
||||
- `OPENAI_VIDEO_MIN_SECONDS` — 默认 `60`;仅用于 OpenAI Sora 目标时长;Seedance 当前单任务按模型可接受的 15 秒上限提交
|
||||
- `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_API_BASE` — 默认 `https://ark.cn-beijing.volces.com/api/v3`
|
||||
- `PUBLIC_APP_URL` — 生产填公网入口,用于把 `/api/img/...` 补成 Seedance 可访问的绝对 URL
|
||||
@@ -62,7 +62,7 @@
|
||||
## 规则
|
||||
- 全项目规则真源:`/Users/kangwan/Projects/code/20260317-rules-dashboard/RULES.md`
|
||||
- 文本/结构化/图片生成统一使用 GPT 最高规格配置
|
||||
- 视频生成默认使用 OpenAI Sora;Seedance 只作为可选回退 provider
|
||||
- 视频生成默认使用 Seedance;OpenAI Sora 只作为可选回退 provider;当前 Seedance 模型不支持单次 60 秒直出
|
||||
- 不允许编造不存在的部署域名、账号、密码
|
||||
|
||||
## 图像链路事实
|
||||
@@ -100,7 +100,7 @@
|
||||
5. 锁定角色设定 `CharacterSpec`
|
||||
6. 串行生成图片包:必须从专利包开始,顺序为 `专利包 -> 配件包 -> 生产打样包 -> 宣发包`
|
||||
7. 前一个图片包完整生成后,下一个图片包才解锁;不提供“一键全包”入口或全包 API
|
||||
8. 四个图片包完成后,才解锁文案模板和 OpenAI Sora 视频任务:旋转展示、开箱、触感细节、角色故事、工厂预览;每条视频目标时长不少于 60 秒
|
||||
8. 四个图片包完成后,才解锁文案模板和视频任务:旋转展示、开箱、触感细节、角色故事、工厂预览;Seedance 单任务按 15 秒提交,60 秒成片需要分段拼接或使用 OpenAI Sora 回退
|
||||
9. 侧栏保留历史会话,点击切换
|
||||
|
||||
## 后续路线
|
||||
|
||||
@@ -4,13 +4,14 @@ GPT_TEXT_MODEL=gpt-5.5
|
||||
GPT_IMAGE_MODEL=gpt-image-2
|
||||
GPT_API_BASE=https://api.openai.com/v1
|
||||
|
||||
# Video generation defaults to OpenAI Sora. Set VIDEO_PROVIDER=seedance only for fallback.
|
||||
VIDEO_PROVIDER=openai_sora
|
||||
# Video generation defaults to Seedance. Set VIDEO_PROVIDER=openai_sora only for fallback.
|
||||
VIDEO_PROVIDER=seedance
|
||||
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_QUALITY=high
|
||||
|
||||
# Optional Seedance fallback.
|
||||
# Seedance is the default video provider.
|
||||
SEEDANCE_API_KEY=
|
||||
SEEDANCE_MODEL=doubao-seedance-2-0-260128
|
||||
SEEDANCE_API_BASE=https://ark.cn-beijing.volces.com/api/v3
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
"docker:down": "docker compose down",
|
||||
"docker:logs": "docker compose logs -f web",
|
||||
"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": {
|
||||
"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 {
|
||||
return Math.min(Math.max(duration ?? 6, 3), 15);
|
||||
return Math.min(Math.max(duration ?? 15, 3), 15);
|
||||
}
|
||||
|
||||
function openAITargetDuration(duration?: number): number {
|
||||
@@ -113,8 +113,10 @@ function withProductVideoConstraints(prompt: string, targetDuration: number): st
|
||||
return [
|
||||
prompt.trim(),
|
||||
'',
|
||||
'硬性约束:主角必须是“有你家族 · 糯糯猪”智能陪伴毛绒娃娃,整体成品高度约 45cm,必须明显是 40cm 以上的大尺寸抱抱玩偶。',
|
||||
'保持浅粉毛绒、圆胖坐姿、黑亮眼睛、粉色猪鼻、下垂耳朵、金色挂绳和爱心吊牌;不要改成普通小挂件、钥匙扣或低于 40cm 的小公仔。',
|
||||
'硬性尺寸约束:主角必须是“有你家族 · 糯糯猪”智能陪伴毛绒娃娃,整体成品高度约 45cm,正面宽约 32cm,侧深约 28cm,背面宽约 33cm。',
|
||||
'必须明显是 40cm 以上的大尺寸抱抱玩偶:优先用成人双手、前臂、胸前怀抱、包装盒、椅子或床品证明比例;不能像掌心小玩偶、桌面摆件、挂件或钥匙扣。',
|
||||
'如果参考图有中文或数字,只把它们当作尺寸比例依据;成片中不要生成任何数字、厘米文字、箭头尺寸标注或文字海报,避免出现错误读数。',
|
||||
'保持浅粉长绒、圆胖坐姿、黑亮眼睛、粉色猪鼻、下垂耳朵、金色挂绳和爱心吊牌;不要改成普通小挂件、钥匙扣或低于 40cm 的小公仔。',
|
||||
`目标视频总时长不少于 ${targetDuration} 秒;如果 API 需要分段或延展,保持同一角色、同一尺寸比例和连续镜头语言。`,
|
||||
].join('\n');
|
||||
}
|
||||
@@ -154,7 +156,10 @@ export async function generateSeedanceVideo(opts: VideoGenerationRequest): Promi
|
||||
if (!key) throw new Error('SEEDANCE_API_KEY missing');
|
||||
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> = {
|
||||
model: SEEDANCE_MODEL,
|
||||
content,
|
||||
|
||||
Reference in New Issue
Block a user