From a0c8f5e6d0e449e6ee002c1411112787af00ed06 Mon Sep 17 00:00:00 2001 From: kang Date: Sat, 30 May 2026 20:06:18 +0800 Subject: [PATCH] feat: add Seedance segmented video workflow --- .env.local.example | 7 +- .project.json | 6 +- RULES.md | 14 +- deploy/.env.production.example | 7 +- package.json | 3 +- scripts/seedance-60s-compose.mjs | 529 +++++++++++++++++++++++++++++++ src/lib/videoProviders.ts | 13 +- 7 files changed, 558 insertions(+), 21 deletions(-) create mode 100644 scripts/seedance-60s-compose.mjs diff --git a/.env.local.example b/.env.local.example index 8e9db13..0827072 100644 --- a/.env.local.example +++ b/.env.local.example @@ -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 diff --git a/.project.json b/.project.json index e7a7e74..21d537f 100644 --- a/.project.json +++ b/.project.json @@ -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" ], diff --git a/RULES.md b/RULES.md index b4475f5..ea4503c 100644 --- a/RULES.md +++ b/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. 侧栏保留历史会话,点击切换 ## 后续路线 diff --git a/deploy/.env.production.example b/deploy/.env.production.example index 9c803f4..7222405 100644 --- a/deploy/.env.production.example +++ b/deploy/.env.production.example @@ -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 diff --git a/package.json b/package.json index 1fe65f1..89dd974 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/seedance-60s-compose.mjs b/scripts/seedance-60s-compose.mjs new file mode 100644 index 0000000..509e586 --- /dev/null +++ b/scripts/seedance-60s-compose.mjs @@ -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 [--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); +} diff --git a/src/lib/videoProviders.ts b/src/lib/videoProviders.ts index b890288..fb690cf 100644 --- a/src/lib/videoProviders.ts +++ b/src/lib/videoProviders.ts @@ -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 = { model: SEEDANCE_MODEL, content,