Add cross-episode continuity and tail-frame dependency scheduling

This commit is contained in:
Codex
2026-11-12 10:38:56 +08:00
parent f831bab486
commit e20e58ba40
18 changed files with 664 additions and 14 deletions
+1
View File
@@ -34,6 +34,7 @@ huobao-source/.git/
!/integration-work/browser-test/*.cjs
/integration-work/inspect_shot05_*.py
/integration-work/transcribe_shot05.py
/integration-work/implement_continuity*.py
# Dependencies and generated application bundles.
node_modules/
+15
View File
@@ -10,6 +10,21 @@
- 角色、场景、道具的图片参考图上传与编辑接口。
- 本地长任务持续查询、重启后恢复查询和原任务结果恢复。
- 固定数据目录、一键启动、升级备份与真人实拍、仙侠风格预设。
- 跨集结尾上下文、分镜起止状态、前镜头尾帧接续与持久化依赖队列。
## 镜头衔接
视频制作中、视频提示词上方的「镜头衔接」面板提供三种模式:
- **独立镜头 / 转场**:不依赖前段,适合换场景、时间跳跃或开场。
- **状态承接**:将前镜头结束状态与本镜头起止状态加入生成约束;允许换角度,仍使用本镜头的资产参考图。
- **尾帧接续**:同场景连续动作。等待前段完成,从所选视频结束前 0.05–3 秒提取画面作为单张首帧,再向选定 GPU 提交。支持当前本地 MiniMax-H3、MiniMax-H3-Hybrid、Wan2.2-I2V;不与多张参考图或参考音色混用。
来源默认为按集号、镜号排序的前一镜,也可选择本项目更早的镜头。不能引用未来、自身、其他项目或已删除的镜头。第一镜、转场需用独立模式。拆分镜工具会读取上一集结尾剧本及最后三个分镜;起止状态是文字设定,不代表系统已经识别或审核了成片画面。
已有分镜升级后保持原模式、原视频,不会自动重新生成。新拆分时 Agent 会填写起止状态并选择衔接方式;旧分镜可在面板中补填后保存。尾帧预览仅提取画面,不生成新视频。前镜头失败时下游保持等待;可取消等待,或先处理前镜头。等待中的任务可重启恢复,并保留原模型与显卡选择。
前镜头版本或起止设定改变后,已生成的下游视频显示核对提示,旧版保留,不自动重跑。依赖链按顺序提交,独立镜头仍可多卡并行。VDN-H3 高清制作仍使用其参考图工作流,不支持固定首帧接续。
## 目录
+6 -1
View File
@@ -358,7 +358,12 @@ function buildInstructions(type: string) {
const skillInstructions = await loadAgentSkills(type, lang)
const languageDirective = buildLanguageDirective(lang)
const voiceInstructions = getVoiceReferenceInstructions(type, lang)
return [baseInstructions, skillInstructions, voiceInstructions, languageDirective]
const continuityInstructions = ['storyboard_breaker', 'prompt_generator'].includes(type) ? `
跨镜头与跨集衔接:read_storyboard_context 返回 previous_episode(上一集剧本结尾、最后三个分镜及起止状态),必须先核对本集开场是接续、时间跳跃还是转场。不得把文字设定当成已经看过的视频画面,缺失信息注明待确认。
拆分镜时每个段落填写 continuity_start 和 continuity_end:记录人物站位、朝向、服装、伤势、道具与持物手、光线、动作起止;不要增加剧情事实。continuity_mode 默认采用 context(状态承接);本项目第一镜或明确转场/时间跳跃用 none;只有同场景、同机位、明确连续动作且本次模型支持首帧(本地 MiniMax-H3、MiniMax-H3-Hybrid、Wan2.2-I2V)才用 first_frame。上一镜按集号和镜号自动确定,禁止要求后面的镜头作为来源。
视频提示词必须服从本镜头起止状态及前镜头结束状态;前情只用于衔接,不得重演上一段,也不得把状态说明朗读为台词。状态承接允许换角度,但保持轴线、视线和运动方向一致。first_frame 镜头开场必须从前段结束画面继续;不混入多个参考图编号。
更新单个 video_prompt 时仍只更新提示词,不改起止状态或衔接设置。` : ''
return [baseInstructions, skillInstructions, voiceInstructions, languageDirective, continuityInstructions]
.filter(Boolean)
.join('\n\n')
}
@@ -10,6 +10,7 @@ import { and, eq, isNull } from 'drizzle-orm'
import { now } from '../../utils/response.js'
import { logTaskProgress, logTaskSuccess } from '../../utils/task-logger.js'
import { getDramaId, getEpisodeId } from '../context.js'
import { previousEpisodeContext } from '../../services/continuity.js'
import { resolveVoiceMentions } from '../../services/character-voice-references.js'
async function validateVideoPromptVoices(prompt: string | undefined, dramaId: number, characterIds: number[] = []) {
@@ -209,6 +210,7 @@ const readStoryboardContext = createTool({
description: sb.description || '',
atmosphere: sb.atmosphere || '',
video_prompt: sb.videoPrompt || '',
continuity_mode: sb.continuityMode, continuity_start: sb.continuityStart, continuity_end: sb.continuityEnd,
}
}))
@@ -223,6 +225,7 @@ const readStoryboardContext = createTool({
characters,
scenes,
props,
previous_episode: await previousEpisodeContext(episodeId),
existing_storyboards: existingStoryboardPayload,
}
logTaskSuccess('StoryboardTool', 'read-context', {
@@ -240,6 +243,9 @@ const readStoryboardContext = createTool({
const storyboardFields = z.object({
shot_number: z.number(),
continuity_mode: z.enum(['none', 'context', 'first_frame']).optional().describe('none: independent/new scene; context: inherit state across a cut; first_frame: same-scene continuous action using previous video tail; automatic predecessor is previous chronological shot'),
continuity_start: z.string().max(1200).optional().describe('Opening state: positions, facing, costumes, injuries, held objects/hands, lighting, ongoing action; unknowns explicitly marked'),
continuity_end: z.string().max(1200).optional().describe('Planned ending state for the next shot; not an observation of generated video'),
title: z.string().optional(),
shot_type: z.string().optional(),
angle: z.string().optional(),
@@ -313,6 +319,7 @@ const saveStoryboards = createTool({
atmosphere: sb.atmosphere, imagePrompt: sb.image_prompt,
videoPrompt: sb.video_prompt, bgmPrompt: sb.bgm_prompt,
soundEffect: sb.sound_effect,
continuityMode: sb.continuity_mode, continuityStart: sb.continuity_start, continuityEnd: sb.continuity_end,
sceneId: sb.scene_id, duration: sb.duration || 10,
updatedAt: ts,
}).where(eq(schema.storyboards.id, existingId))
@@ -329,6 +336,7 @@ const saveStoryboards = createTool({
atmosphere: sb.atmosphere, imagePrompt: sb.image_prompt,
videoPrompt: sb.video_prompt, bgmPrompt: sb.bgm_prompt,
soundEffect: sb.sound_effect,
continuityMode: sb.continuity_mode, continuityStart: sb.continuity_start, continuityEnd: sb.continuity_end,
sceneId: sb.scene_id, duration: sb.duration || 10,
createdAt: ts, updatedAt: ts,
})
+5
View File
@@ -127,6 +127,11 @@ export const storyboards = sqliteTable('storyboards', {
soundEffect: text('sound_effect'),
description: text('description'),
duration: integer('duration').default(0),
continuityMode: text('continuity_mode').default('none'),
continuitySourceId: integer('continuity_source_id'),
continuityStart: text('continuity_start'),
continuityEnd: text('continuity_end'),
continuityOffsetMs: integer('continuity_offset_ms').default(200),
composedImage: text('composed_image'),
firstFrameImage: text('first_frame_image'),
lastFrameImage: text('last_frame_image'),
@@ -391,6 +391,11 @@ export function initSqliteSchema(sqlite: Database.Database) {
for (const name of ['voice_audio_url', 'voice_audio_name']) {
if (!characterColumns.has(name)) sqlite.exec(`ALTER TABLE characters ADD COLUMN ${name} TEXT`)
}
// Additive migration: existing shots stay independent and all media remain untouched.
const storyboardColumns = new Set((sqlite.prepare('PRAGMA table_info(storyboards)').all() as { name: string }[]).map(c => c.name))
for (const [name, type] of Object.entries({ continuity_mode: "TEXT DEFAULT 'none'", continuity_source_id: 'INTEGER', continuity_start: 'TEXT', continuity_end: 'TEXT', continuity_offset_ms: 'INTEGER DEFAULT 200' })) {
if (!storyboardColumns.has(name)) sqlite.exec(`ALTER TABLE storyboards ADD COLUMN ${name} ${type}`)
}
const insertSeed = sqlite.prepare(SEED_SQL)
// Reference inputs are independent of generated outputs; preserve all existing rows.
const sceneColumns = new Set((sqlite.prepare('PRAGMA table_info(scenes)').all() as { name: string }[]).map(c => c.name))
@@ -5,7 +5,31 @@ import { success, created, now, badRequest } from '../utils/response.js'
import { toSnakeCase } from '../utils/transform.js'
import { logTaskPayload, logTaskStart, logTaskSuccess } from '../utils/task-logger.js'
import { continuityDetails, continuityUpdate, extractContinuityFrame, resolveContinuity, sourceReadiness } from '../services/continuity.js'
const app = new Hono()
app.get('/:id/continuity', async c => {
try { return success(c, await continuityDetails(Number(c.req.param('id')))) }
catch (error: any) { return badRequest(c, error.message) }
})
app.put('/:id/continuity', async c => {
try {
const id = Number(c.req.param('id'))
const updates = continuityUpdate(await c.req.json())
await resolveContinuity(id, updates)
await db.update(schema.storyboards).set({ ...updates, updatedAt: now() }).where(eq(schema.storyboards.id, id))
return success(c, await continuityDetails(id))
} catch (error: any) { return badRequest(c, error.message) }
})
app.post('/:id/continuity/preview', async c => {
try {
const { plan } = await resolveContinuity(Number(c.req.param('id')))
if (!plan.sourceId) return badRequest(c, '请先选择衔接来源')
const source = await sourceReadiness(plan.sourceId)
if (source.state !== 'ready') return badRequest(c, '前镜头尚未完成,请先完成或恢复前镜头')
return success(c, { frame: await extractContinuityFrame(source.video, plan.offsetMs), sourceVideo: source.video })
} catch (error: any) { return badRequest(c, error.message) }
})
async function syncStoryboardCharacters(storyboardId: number, characterIds: number[]) {
await db.delete(schema.storyboardCharacters)
+6 -1
View File
@@ -2,7 +2,7 @@ import { Hono } from 'hono'
import { and, desc, eq } from 'drizzle-orm'
import { db, schema } from '../db/index.js'
import { success, created, badRequest } from '../utils/response.js'
import { generateImage, generateVideo, recoverGenerationTask } from '../services/generation.js'
import { generateImage, generateVideo, recoverGenerationTask, cancelWaitingVideo } from '../services/generation.js'
import { safeTaskError } from '../services/video-download.js'
import { taskRouting } from '../services/worker-routing.js'
import { getActiveConfig, getConfigById } from '../services/ai.js'
@@ -12,6 +12,11 @@ import { logTaskError, logTaskPayload, logTaskStart, logTaskSuccess } from '../u
const app = new Hono()
app.post('/:id/cancel-waiting', async c => {
try { await cancelWaitingVideo(Number(c.req.param('id'))); return success(c) }
catch (error: any) { return badRequest(c, error.message) }
})
app.post('/:id/recover', async c => {
try {
await recoverGenerationTask(Number(c.req.param('id')))
@@ -0,0 +1,190 @@
/** Persisted shot continuity. Planned states are never presented as observed video facts. */
import { and, desc, eq } from 'drizzle-orm'
import fs from 'node:fs'
import path from 'node:path'
import { createHash, randomUUID } from 'node:crypto'
import { db, schema } from '../db/index.js'
import { STORAGE_ROOT } from '../utils/paths.js'
import { ffmpeg } from '../utils/ffmpeg.js'
import type { AIConfig } from './adapters/types.js'
export type ContinuityMode = 'none' | 'context' | 'first_frame'
type Shot = typeof schema.storyboards.$inferSelect
export interface ContinuityPlan {
mode: ContinuityMode
sourceId: number | null
startState: string
endState: string
sourceEndState: string
offsetMs: number
}
export function taskParams(raw?: string | null): any { try { return JSON.parse(raw || '{}') } catch { return {} } }
export async function orderedProjectShots(episodeId: number) {
const episode = await db.select().from(schema.episodes).where(eq(schema.episodes.id, episodeId)).get()
if (!episode || episode.deletedAt) throw new Error('当前集不存在或已删除')
const episodes = (await db.select().from(schema.episodes).where(eq(schema.episodes.dramaId, episode.dramaId)))
.filter(e => !e.deletedAt).sort((a, b) => a.episodeNumber - b.episodeNumber || a.id - b.id)
const rows = await db.select().from(schema.storyboards)
const shots = episodes.flatMap(ep => rows.filter(s => !s.deletedAt && s.episodeId === ep.id)
.sort((a, b) => a.storyboardNumber - b.storyboardNumber || a.id - b.id)
.map(s => ({ ...s, episodeNumber: ep.episodeNumber })))
return { episode, episodes, shots }
}
export async function resolveContinuity(id: number, override: Partial<Shot> = {}) {
const row = await db.select().from(schema.storyboards).where(eq(schema.storyboards.id, id)).get()
if (!row || row.deletedAt) throw new Error('分镜不存在或已删除')
const shot = { ...row, ...override }
const { episode, shots } = await orderedProjectShots(shot.episodeId)
const position = shots.findIndex(s => s.id === id)
const candidates = shots.slice(0, position)
const source = shot.continuitySourceId != null
? candidates.find(s => s.id === shot.continuitySourceId) : candidates.at(-1)
if (shot.continuitySourceId != null && !source) throw new Error('衔接来源必须是本项目中更早且未删除的分镜,不能引用自身或后续镜头')
const mode = (shot.continuityMode || 'none') as ContinuityMode
if (!['none', 'context', 'first_frame'].includes(mode)) throw new Error('衔接模式无效')
if (mode !== 'none' && !source) throw new Error('没有可承接的前镜头,请使用独立镜头模式')
if (mode === 'first_frame' && (!shot.sceneId || shot.sceneId !== source?.sceneId)) {
throw new Error('尾帧接续仅用于同一场景的连续动作;换场景请选择状态承接或独立镜头')
}
const plan: ContinuityPlan = { mode, sourceId: mode === 'none' ? null : source!.id,
startState: shot.continuityStart || '', endState: shot.continuityEnd || '',
sourceEndState: mode === 'none' ? '' : source!.continuityEnd || '', offsetMs: shot.continuityOffsetMs ?? 200 }
return { shot, source: mode === 'none' ? undefined : source, candidates, episode, plan }
}
export function continuityUpdate(body: any): Partial<Shot> {
const result: Partial<Shot> = {}
if ('continuity_mode' in body) {
if (!['none', 'context', 'first_frame'].includes(body.continuity_mode)) throw new Error('衔接模式无效')
result.continuityMode = body.continuity_mode
}
if ('continuity_source_id' in body) {
const id = body.continuity_source_id
if (id !== null && (!Number.isSafeInteger(id) || id <= 0)) throw new Error('前镜头编号无效')
result.continuitySourceId = id
}
for (const [key, target] of [['continuity_start', 'continuityStart'], ['continuity_end', 'continuityEnd']] as const) {
if (!(key in body)) continue
if (typeof body[key] !== 'string' || body[key].length > 1200) throw new Error('镜头起止状态最多 1200 字')
result[target] = body[key].trim()
}
if ('continuity_offset_ms' in body) {
if (!Number.isInteger(body.continuity_offset_ms) || body.continuity_offset_ms < 50 || body.continuity_offset_ms > 3000) throw new Error('取帧位置需为结束前 50–3000 毫秒')
result.continuityOffsetMs = body.continuity_offset_ms
}
// Switching to independent mode must also let users detach a deleted source.
if (result.continuityMode === 'none') result.continuitySourceId = null
return result
}
export function validateContinuityModel(plan: ContinuityPlan, config: AIConfig, model?: string | null) {
if (plan.mode !== 'first_frame') return
let local = false
try { const u = new URL(config.baseUrl); local = u.hostname === '192.168.110.111' && ['18792', '18794'].includes(u.port) } catch {}
// These local workflows implement genuine image-to-video; VDN/R2V-only workflows do not.
if (!local || !['MiniMax-H3', 'MiniMax-H3-Hybrid', 'Wan2.2-I2V'].includes(model || config.model || '')) {
throw new Error('尾帧接续请选择本地 MiniMax-H3、MiniMax-H3-Hybrid 或 Wan2.2-I2V;当前模型可使用状态承接')
}
}
export function continuityPrompt(prompt: string, plan: ContinuityPlan) {
const instructions = [
plan.mode !== 'none' && plan.sourceEndState ? `前镜头结束状态(文字设定):${plan.sourceEndState}` : '',
plan.startState ? `本镜头开始状态:${plan.startState}` : '',
plan.endState ? `本镜头结束状态:${plan.endState}` : '',
plan.mode === 'first_frame' ? '以提供的首帧承接前镜头结束画面,保持人物身份、衣着、持物手、站位、朝向、光线和动作方向连续;从该状态继续动作,不重演上一段。' : '',
].filter(Boolean)
if (!instructions.length) return prompt
// In first-frame mode there are no numbered multi-image references in the request.
const text = plan.mode === 'first_frame' ? prompt.replace(/@图片\d+/g, '@') : prompt
return `${text}\n【画面衔接约束,仅指导画面,禁止朗读或作为新增台词】\n${instructions.join('\n')}`
}
export async function sourceReadiness(sourceId: number) {
const source = await db.select().from(schema.storyboards).where(eq(schema.storyboards.id, sourceId)).get()
if (!source || source.deletedAt) throw new Error('前镜头已删除,请重新选择衔接来源')
const rows = (await db.select().from(schema.sysTask).where(and(eq(schema.sysTask.type, 'video'), eq(schema.sysTask.storyboardId, sourceId)))
.orderBy(desc(schema.sysTask.id))).filter(t => taskParams(t.params).variant !== 'hd')
const latest = rows[0]
if (latest?.status === 'processing') return { source, state: 'waiting_predecessor' as const, video: '', taskId: latest.id }
if (latest?.status === 'failed') return { source, state: 'blocked_predecessor' as const, video: '', taskId: latest.id }
const video = source.videoUrl || ''
const selected = rows.find(t => t.status === 'completed' && (t.localPath === video || t.resultUrl === video))
return { source, state: video ? 'ready' as const : 'waiting_predecessor' as const, video, taskId: selected?.id ?? null }
}
function localVideoFile(relative: string) {
const rel = relative.replace(/^\//, '')
if (!rel.startsWith('static/') || rel.includes('\\') || rel.split('/').includes('..')) throw new Error('前镜头视频尚未保存到本地,请先恢复视频')
const root = fs.realpathSync(STORAGE_ROOT)
const file = fs.realpathSync(path.join(root, rel.slice(7)))
const inside = path.relative(root, file)
if (!inside || inside.startsWith('..') || path.isAbsolute(inside)) throw new Error('前镜头视频路径无效')
return file
}
const extracting = new Map<string, Promise<string>>()
export async function extractContinuityFrame(video: string, offsetMs: number): Promise<string> {
const file = localVideoFile(video)
const stat = fs.statSync(file)
const key = createHash('sha256').update(`${video}:${stat.size}:${stat.mtimeMs}:${offsetMs}`).digest('hex').slice(0, 32)
const relative = `static/continuity/${key}.jpg`
const destination = path.join(STORAGE_ROOT, 'continuity', `${key}.jpg`)
if (fs.existsSync(destination) && fs.statSync(destination).size > 0) return relative
if (extracting.has(key)) return extracting.get(key)!
const job = (async () => {
fs.mkdirSync(path.dirname(destination), { recursive: true })
const temporary = path.join(path.dirname(destination), `${key}-${randomUUID()}.jpg`)
try {
const duration = await new Promise<number>((resolve, reject) => ffmpeg.ffprobe(file, (error, metadata) => error ? reject(error) : resolve(Number(metadata.format.duration))))
if (!Number.isFinite(duration) || duration <= 0) throw new Error('无法读取前镜头视频时长')
await new Promise<void>((resolve, reject) => {
const command = ffmpeg(file).seekInput(Math.max(0, duration - offsetMs / 1000)).frames(1)
.outputOptions(['-q:v 2']).output(temporary)
const timer = setTimeout(() => { command.kill('SIGKILL'); reject(new Error('提取尾帧超时,请先检查前镜头视频')) }, 30_000)
command.on('end', () => { clearTimeout(timer); resolve() }).on('error', error => { clearTimeout(timer); reject(error) }).run()
})
if (!fs.existsSync(temporary) || !fs.statSync(temporary).size) throw new Error('尾帧为空,请调整取帧位置')
fs.renameSync(temporary, destination)
return relative
} finally { if (fs.existsSync(temporary)) fs.unlinkSync(temporary) }
})()
extracting.set(key, job)
try { return await job } finally { extracting.delete(key) }
}
export async function previousEpisodeContext(episodeId: number) {
const { episode, episodes, shots } = await orderedProjectShots(episodeId)
const previous = episodes[episodes.findIndex(e => e.id === episodeId) - 1]
if (!previous) return null
return { episode_id: previous.id, episode_number: previous.episodeNumber, title: previous.title,
script_ending: (previous.scriptContent || previous.content || '').slice(-3500),
ending_storyboards: shots.filter(s => s.episodeId === previous.id).slice(-3).map(s => ({
id: s.id, shot_number: s.storyboardNumber, scene_id: s.sceneId, description: s.description,
video_prompt: s.videoPrompt, continuity_start: s.continuityStart, continuity_end: s.continuityEnd,
has_video: !!s.videoUrl,
})), evidence: '剧本与分镜的文字设定,不代表已识别实际视频画面;缺失状态不得臆测', current_episode: episode.episodeNumber }
}
export async function continuityDetails(id: number) {
const resolved = await resolveContinuity(id)
const { source, candidates, plan } = resolved
const ready = source ? await sourceReadiness(source.id) : null
const tasks = await db.select().from(schema.sysTask).where(and(eq(schema.sysTask.type, 'video'), eq(schema.sysTask.storyboardId, id))).orderBy(desc(schema.sysTask.id))
const displayed = tasks.find(t => t.status === 'completed' && (t.localPath === resolved.shot.videoUrl || t.resultUrl === resolved.shot.videoUrl))
const used = taskParams(displayed?.params).continuity
let stale = false
if (used?.sourceId) {
try {
const oldSource = await sourceReadiness(used.sourceId)
stale = oldSource.state !== 'ready' || used.sourceVideo !== oldSource.video || used.sourceEndState !== (oldSource.source.continuityEnd || '')
|| used.sourceId !== plan.sourceId || used.mode !== plan.mode || used.startState !== plan.startState || used.endState !== plan.endState || used.offsetMs !== plan.offsetMs
} catch { stale = true }
}
return { ...plan, source: source ? { id: source.id, episodeNumber: source.episodeNumber, shotNumber: source.storyboardNumber,
endState: source.continuityEnd || '', description: source.description, video: source.videoUrl, readiness: ready?.state } : null,
candidates: candidates.map(s => ({ id: s.id, label: `第 ${s.episodeNumber} 集 · 分镜 ${s.storyboardNumber}`, sceneId: s.sceneId })),
stale, usedFrame: used?.frameUrl || null, hasPriorVideoWithoutContinuity: !!displayed && plan.mode !== 'none' && !used?.sourceId }
}
@@ -19,6 +19,8 @@ import { getImageAdapter, getVideoAdapter } from './adapters/registry'
import type { AIConfig } from './adapters/types'
import { logTaskError, logTaskPayload, logTaskProgress, logTaskStart, logTaskSuccess, logTaskWarn, redactUrl } from '../utils/task-logger.js'
import { continuityPrompt, extractContinuityFrame, resolveContinuity, sourceReadiness, validateContinuityModel, type ContinuityPlan } from './continuity.js'
type TaskType = 'image' | 'video'
const activeTasks = new Set<number>()
@@ -131,11 +133,22 @@ export async function generateVideo(params: GenerateVideoParams): Promise<number
params.referenceAudioUrls = await prepareReferenceAudio(params.referenceAudioUrls, config.provider)
const continuity = storyboardId && params.variant !== 'hd' ? (await resolveContinuity(storyboardId)).plan : null
if (continuity) validateContinuityModel(continuity, config, params.model)
if (continuity?.mode === 'first_frame' && params.referenceAudioUrls?.length) throw new Error('当前尾帧接续工作流不支持音色参考,请改为状态承接')
const previewParams = { ...params, prompt: continuity ? continuityPrompt(params.prompt, continuity) : params.prompt }
if (continuity?.mode === 'first_frame') {
// A validation-only placeholder; never submitted as a generation input.
previewParams.firstFrameUrl = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aN1kAAAAASUVORK5CYII='
previewParams.imageUrl = undefined; previewParams.lastFrameUrl = undefined
previewParams.referenceImageUrls = []; previewParams.referenceVideoUrls = []
previewParams.referenceFileUrl = undefined; previewParams.referenceLinkUrl = undefined
}
// Validate the new request before touching the previous job.
const validatedRequest = getVideoAdapter(config.provider).buildGenerateRequest(config, { ...params, id: 0,
referenceImageUrls: JSON.stringify(params.referenceImageUrls || []),
referenceVideoUrls: JSON.stringify(params.referenceVideoUrls || []),
referenceAudioUrls: JSON.stringify(params.referenceAudioUrls || []) })
const validatedRequest = getVideoAdapter(config.provider).buildGenerateRequest(config, { ...previewParams, id: 0,
referenceImageUrls: JSON.stringify(previewParams.referenceImageUrls || []),
referenceVideoUrls: JSON.stringify(previewParams.referenceVideoUrls || []),
referenceAudioUrls: JSON.stringify(previewParams.referenceAudioUrls || []) })
if (params.replaceTaskId && ['minimax', 'localvideo'].includes(config.provider)) {
const target = new URL(config.baseUrl)
if (target.hostname === '192.168.110.111' && ['18792', '18794'].includes(target.port)) {
@@ -161,6 +174,7 @@ export async function generateVideo(params: GenerateVideoParams): Promise<number
prompt: params.prompt,
model: params.model || config.model,
}, {
continuity,
referenceMode: params.referenceMode || 'reference',
variant: params.variant,
originalVideoUrl: params.originalVideoUrl,
@@ -202,7 +216,19 @@ export async function generateVideo(params: GenerateVideoParams): Promise<number
}
}
export async function cancelWaitingVideo(id: number) {
const result = await db.update(schema.sysTask).set({ status: 'failed', stage: 'failed', errorCode: 'user_cancelled',
errorMsg: '已取消等待前镜头的任务,未向显卡提交生成', updatedAt: now() })
.where(and(eq(schema.sysTask.id, id), eq(schema.sysTask.status, 'processing'), isNull(schema.sysTask.taskId),
or(eq(schema.sysTask.stage, 'waiting_predecessor'), eq(schema.sysTask.stage, 'blocked_predecessor')))).returning({ id: schema.sysTask.id })
if (!result.length) throw new Error('任务已经开始提交或结束,请刷新后再操作')
}
async function cancelVideoForReplacement(record: SysTaskRecord) {
if (!record.taskId && ['waiting_predecessor', 'blocked_predecessor'].includes(record.stage || '')) {
await cancelWaitingVideo(record.id)
return
}
if (!record.taskId || record.stage === 'submitting') throw new Error('原任务尚未返回编号,请稍后再换卡')
if (record.resultUrl || ['downloading', 'saving', 'transferring'].includes(record.stage || '')) throw new Error('原视频正在保存,请完成后再重新生成')
const config = await recoveryConfig(record)
@@ -255,7 +281,7 @@ async function createTask(
provider: config.provider,
configId: config.id,
configBaseUrl: config.baseUrl,
stage: 'submitting',
stage: (params.continuity as ContinuityPlan | undefined)?.mode === 'first_frame' ? 'waiting_predecessor' : 'submitting',
params: JSON.stringify({ ...params, gpu: config.gpu || 'auto' }),
status: 'processing',
createdAt: ts,
@@ -276,10 +302,47 @@ function parseTaskParams(raw: string | null | undefined): Record<string, any> {
}
}
async function prepareContinuityTask(record: SysTaskRecord): Promise<boolean> {
const params = parseTaskParams(record.params)
const plan = params.continuity as ContinuityPlan | undefined
if (!plan || !record.storyboardId) return true
while (true) {
const current = await db.select().from(schema.sysTask).where(eq(schema.sysTask.id, record.id)).get()
if (!current || current.status !== 'processing') return false
const fresh = (await resolveContinuity(record.storyboardId)).plan
if (JSON.stringify(fresh) !== JSON.stringify(plan)) throw new Error('镜头衔接设置或前镜头结束状态已变化,请重新提交此镜头')
const source = plan.sourceId ? await sourceReadiness(plan.sourceId) : null
if (plan.mode === 'first_frame' && source?.state !== 'ready') {
await db.update(schema.sysTask).set({ stage: source?.state || 'waiting_predecessor', updatedAt: now() })
.where(and(eq(schema.sysTask.id, record.id), eq(schema.sysTask.status, 'processing')))
await new Promise(resolve => setTimeout(resolve, 2000))
continue
}
if (plan.mode === 'first_frame' && source) {
const frameUrl = await extractContinuityFrame(source.video, plan.offsetMs)
const after = await sourceReadiness(plan.sourceId!)
if (after.state !== 'ready' || after.video !== source.video || after.taskId !== source.taskId) continue
params.firstFrameUrl = frameUrl; params.imageUrl = undefined; params.lastFrameUrl = undefined
params.referenceImageUrls = []; params.referenceVideoUrls = []; params.referenceAudioUrls = []
params.referenceFileUrl = undefined; params.referenceLinkUrl = undefined
params.referenceMode = 'first_frame'
params.continuity = { ...plan, sourceVideo: source.video, sourceTaskId: source.taskId, frameUrl }
} else {
params.continuity = { ...plan, sourceVideo: source?.video || '', sourceTaskId: source?.taskId ?? null }
}
const prompt = continuityPrompt(record.prompt || '', plan)
const claimed = await db.update(schema.sysTask).set({ params: JSON.stringify(params), prompt, stage: 'submitting', updatedAt: now() })
.where(and(eq(schema.sysTask.id, record.id), eq(schema.sysTask.status, 'processing'), isNull(schema.sysTask.taskId))).returning({ id: schema.sysTask.id })
return claimed.length > 0
}
}
async function processTask(id: number, config: AIConfig) {
try {
const [record] = await db.select().from(schema.sysTask).where(eq(schema.sysTask.id, id))
if (!record) return
let [record] = await db.select().from(schema.sysTask).where(eq(schema.sysTask.id, id))
if (!record || record.status !== 'processing') return
if (record.type === 'video' && !await prepareContinuityTask(record)) return
;[record] = await db.select().from(schema.sysTask).where(eq(schema.sysTask.id, id))
const type = record.type as TaskType
const label = taskLabel(type)
const params = parseTaskParams(record.params)
@@ -352,6 +415,14 @@ async function processTask(id: number, config: AIConfig) {
body: isMultipart ? `[multipart/form-data: ${[...(body as FormData).keys()].join(', ')}]` : body,
})
const beforeSend = await db.select().from(schema.sysTask).where(eq(schema.sysTask.id, id)).get()
if (!beforeSend || beforeSend.status !== 'processing') return
if (params.continuity?.mode === 'first_frame') {
const source = await sourceReadiness(params.continuity.sourceId)
if (source.state !== 'ready' || source.video !== params.continuity.sourceVideo || source.taskId !== params.continuity.sourceTaskId) {
throw new Error('前镜头版本已变化,请重新提交此镜头;尚未发送到显卡')
}
}
const resp = await fetch(url, {
method,
headers,
@@ -737,6 +808,13 @@ export async function resumeInterruptedTasks() {
const tasks = candidates.filter(task => task.status === 'processing' || (!task.errorCode && /^Timeout: (polling attempts exhausted|Polling exceeded 10 minutes)/.test(task.errorMsg || '')
&& isLocalQueuedTask({ provider: task.provider || '', baseUrl: task.configBaseUrl || '' }, task.taskId)))
for (const task of tasks) {
if (task.type === 'video' && !task.taskId && task.status === 'processing' && ['waiting_predecessor', 'blocked_predecessor'].includes(task.stage || '') && parseTaskParams(task.params).continuity?.mode === 'first_frame') {
try {
const config = await recoveryConfig(task)
runBackground(task.id, () => processTask(task.id, { ...config, gpu: parseTaskParams(task.params).gpu || config.gpu }))
} catch (error) { await failTask(task.id, safeTaskError(error), 'recovery_failed') }
continue
}
if ((task.type === 'video' && (task.taskId || task.resultUrl)) || (task.type === 'image' && isLocalQueuedTask({ provider: task.provider || '', baseUrl: task.configBaseUrl || '' }, task.taskId))) {
try { await recoverGenerationTask(task.id) } catch (error) { await failTask(task.id, safeTaskError(error), 'recovery_failed') }
} else {
@@ -0,0 +1,99 @@
<template>
<section class="continuity-panel" aria-label="镜头衔接">
<div class="continuity-heading"><strong>镜头衔接</strong><span>{{ info?.source ? `接第 ${info.source.episodeNumber} 集 · 分镜 ${info.source.shotNumber}` : '独立镜头' }}</span></div>
<p v-if="info?.stale" class="continuity-warning">前镜头版本或衔接状态已改变:本镜头当前视频需要核对,旧视频已保留。</p>
<p v-if="info?.hasPriorVideoWithoutContinuity" class="continuity-warning">此设置用于下一次生成;当前视频尚未应用衔接。</p>
<label>衔接方式
<select v-model="form.continuity_mode" :disabled="saving">
<option value="none">独立镜头 / 转场</option>
<option value="context">状态承接(允许换角度)</option>
<option value="first_frame">尾帧接续(同场景连续动作)</option>
</select>
</label>
<template v-if="form.continuity_mode !== 'none'">
<label>承接来源
<select v-model="form.continuity_source_id" :disabled="saving">
<option :value="null">自动:上一分镜(跨集取上一集结尾)</option>
<option v-for="candidate in info?.candidates || []" :key="candidate.id" :value="candidate.id">{{ candidate.label }}</option>
</select>
</label>
<p v-if="info?.source?.endState" class="continuity-state">前镜头结束状态:{{ info.source.endState }}</p>
<p v-else class="continuity-note">前镜头尚未填写结束状态,请结合实际视频核对;系统不会把文字设定当成已观察到的画面。</p>
</template>
<template v-if="form.continuity_mode === 'first_frame'">
<label>取前段结束前的画面
<select v-model="form.continuity_offset_ms" :disabled="saving"><option :value="50">0.05 秒</option><option :value="200">0.2 秒</option><option :value="500">0.5 秒</option><option :value="1000">1 秒</option><option :value="2000">2 秒</option><option :value="3000">3 秒</option></select>
</label>
<p class="continuity-note">支持本地 MiniMax-H3、H3-Hybrid、Wan2.2-I2V。生成时使用单张首帧,不同时传角色参考图;其他模型请选择状态承接。</p>
<button class="btn btn-sm" :disabled="dirty || saving || previewing || info?.source?.readiness !== 'ready'" @click="preview">{{ previewing ? '提取中…' : '预览前镜头尾帧' }}</button>
<p v-if="info?.source?.readiness === 'waiting_predecessor'" class="continuity-note">前镜头尚未完成;提交后会等待,不占用显卡队列。</p>
<p v-if="info?.source?.readiness === 'blocked_predecessor'" class="continuity-warning">前镜头生成失败,请先恢复或重新生成前镜头。</p>
</template>
<figure v-if="previewFrame || info?.usedFrame"><img :src="'/' + (previewFrame || info.usedFrame).replace(/^\//, '')" alt="衔接参考帧" /><figcaption>{{ previewFrame ? '前镜头取帧预览;下一次提交会核对来源版本' : '当前视频生成时使用的首帧' }}</figcaption></figure>
<label>本镜头开始状态<textarea v-model="form.continuity_start" maxlength="1200" rows="3" :disabled="saving" placeholder="人物站位、朝向、服装、伤势、持物手、动作起点…" /></label>
<label>本镜头结束状态<textarea v-model="form.continuity_end" maxlength="1200" rows="3" :disabled="saving" placeholder="交给下一镜头的状态;可在看过实际视频后修正" /></label>
<p v-if="error" role="alert" class="continuity-warning">{{ error }}</p>
<div class="continuity-actions"><span>{{ dirty ? '有未保存的衔接设置' : '衔接设置已保存' }}</span><button class="btn btn-sm" :disabled="!dirty || saving" @click="save">{{ saving ? '保存中…' : '保存衔接' }}</button></div>
<div v-if="waiting" class="continuity-wait"><span>{{ task?.stage === 'blocked_predecessor' ? '等待处理前镜头失败' : '等待前镜头完成' }}</span><button class="btn btn-sm" :disabled="saving" @click="cancelWaiting">取消等待</button></div>
</section>
</template>
<script setup lang="ts">
import { api } from '~/composables/useApi'
const props = defineProps<{ shot: any; task?: any }>()
const emit = defineEmits<{ saved: []; dirty: [value: boolean] }>()
const info = ref<any>(null), error = ref(''), saving = ref(false), previewing = ref(false), previewFrame = ref('')
const form = ref<any>({}), savedValue = ref('')
const dirty = computed(() => JSON.stringify(form.value) !== savedValue.value)
watch(() => form.value.continuity_mode, mode => { if (mode === 'none') form.value.continuity_source_id = null })
const waiting = computed(() => props.task?.status === 'processing' && ['waiting_predecessor', 'blocked_predecessor'].includes(props.task?.stage))
watch(dirty, value => emit('dirty', value))
async function load() {
const id = props.shot?.id
if (!id) return
try { const value = await api.get(`/storyboards/${id}/continuity`); if (props.shot?.id === id) { info.value = value; error.value = '' } }
catch (e: any) { if (props.shot?.id === id) error.value = e.message }
}
watch(() => props.shot?.id, () => {
info.value = null; error.value = ''; previewFrame.value = ''
form.value = { continuity_mode: props.shot?.continuity_mode || 'none', continuity_source_id: props.shot?.continuity_source_id ?? null,
continuity_start: props.shot?.continuity_start || '', continuity_end: props.shot?.continuity_end || '', continuity_offset_ms: props.shot?.continuity_offset_ms ?? 200 }
savedValue.value = JSON.stringify(form.value); emit('dirty', false); void load()
}, { immediate: true })
async function save() {
const id = props.shot.id, snapshot = JSON.stringify(form.value)
saving.value = true
try { const result = await api.put(`/storyboards/${id}/continuity`, JSON.parse(snapshot)); if (props.shot.id === id) { info.value = result; savedValue.value = snapshot; previewFrame.value = ''; error.value = '' }; emit('saved') }
catch (e: any) { if (props.shot.id === id) error.value = e.message }
finally { saving.value = false }
}
async function preview() {
const id = props.shot.id; previewing.value = true
try { const result = await api.post(`/storyboards/${id}/continuity/preview`); if (props.shot.id === id) { previewFrame.value = result.frame; error.value = '' } }
catch (e: any) { if (props.shot.id === id) error.value = e.message }
finally { previewing.value = false }
}
async function cancelWaiting() {
saving.value = true
try { await api.post(`/tasks/${props.task.id}/cancel-waiting`); emit('saved') }
catch (e: any) { error.value = e.message }
finally { saving.value = false }
}
let interval: ReturnType<typeof setInterval>
onMounted(() => { interval = setInterval(() => { if (!saving.value) void load() }, 10000) })
onBeforeUnmount(() => { clearInterval(interval); emit('dirty', false) })
</script>
<style scoped>
.continuity-panel { display:flex; flex-direction:column; gap:12px; border:1px solid var(--border, #ddd); border-radius:14px; padding:16px; margin:12px 0; background:var(--bg-1, #fff); }
.continuity-heading,.continuity-actions,.continuity-wait { display:flex; align-items:center; justify-content:space-between; gap:8px; }
.continuity-heading span,.continuity-actions span,figcaption { font-size:11px; color:var(--text-3, #777); }
label { display:flex; flex-direction:column; gap:6px; font-size:12px; font-weight:600; }
select,textarea { width:100%; box-sizing:border-box; background:var(--bg-0, #fff); color:var(--text-0, #222); border:1px solid var(--border, #ddd); border-radius:8px; padding:9px; font:inherit; font-weight:400; }
textarea { resize:vertical; line-height:1.55; }
p { margin:0; font-size:12px; line-height:1.6; }
.continuity-note,.continuity-state { color:var(--text-2, #666); }
.continuity-warning { color:#a1490a; background:#fff3e8; padding:9px; border-radius:8px; }
.continuity-wait { font-size:12px; padding-top:10px; border-top:1px solid var(--border, #ddd); }
figure { margin:0; } img { max-width:100%; max-height:220px; border-radius:8px; object-fit:contain; } figcaption { margin-top:5px; }
</style>
@@ -41,6 +41,9 @@ export function mapError(err: unknown, opts?: { fallback?: string }): string {
const msg = messageOf(err).trim()
if (!msg) return opts?.fallback ? t(opts.fallback) : t('errors.unknown')
// Application-authored continuity errors contain actionable model/shot guidance.
if (/^(尾帧接续请选择|当前尾帧接续工作流|镜头衔接设置或前镜头|前镜头版本已变化|衔接来源必须|尾帧接续仅用于|没有可承接的前镜头)/.test(msg) && msg.length < 180 && !/https?:\/\//i.test(msg)) return msg
if (NETWORK_RE.test(msg)) return t('errors.network')
if (err instanceof SyntaxError || NONJSON_RE.test(msg)) return t('errors.server')
if (MODERATION_RE.test(msg)) return t('errors.moderation')
@@ -673,6 +673,7 @@
</section>
</div>
<ShotContinuity v-if="selectedSb" :key="selectedSb.id" :shot="selectedSb" :task="latestVideoTask(selectedSb.id)" @saved="refresh" @dirty="continuityDirty = $event" />
<section class="video-inspector-section">
<div class="video-inspector-prompt-head">
<span class="video-inspector-label video-inspector-label-hero">{{ t('episode.sb.videoPromptSection') }}</span>
@@ -1446,6 +1447,7 @@
<div class="batch-video-row"><span>批量显卡</span><strong>{{ configGpu(selectedVideoConfig) === 'auto' ? '自动分配' : configGpu(selectedVideoConfig).toUpperCase() }}(跟随顶部默认模型)</strong></div>
<div class="batch-video-row"><span>{{ t('episode.vid.confirmResolution') }}</span><strong>{{ episodeResolutionLabel }}</strong></div>
<p class="batch-video-note">{{ t('episode.vid.confirmNote') }}</p>
<p class="batch-video-note">尾帧接续镜头会等待前镜头完成后再提交;独立镜头可并行。若前镜头失败,请先处理前镜头,等待任务不会重复生成。</p>
</div>
<footer class="dialog-foot">
<button class="btn" @click="batchVideoConfirm.open = false">{{ t('common.cancel') }}</button>
@@ -1488,6 +1490,7 @@ const { t } = useI18n()
const route = useRoute()
const dramaId = Number(route.params.id)
const episodeNumber = Number(route.params.episodeNumber)
const continuityDirty = ref(false)
const drama = ref(null), episode = ref(null), chars = ref([]), scenes = ref([]), propItems = ref([]), sbs = ref([]), mergeData = ref(null)
// 工作台面板位置记忆(按剧集隔离):仅页面刷新(reload)时恢复到上次所在步骤;
@@ -1995,7 +1998,7 @@ function isPendingSceneImage(id) {
}
function isPendingVideo(id) {
return pendingVideoIds.value.includes(id)
return latestVideoTask(id)?.status === 'processing' || pendingVideoIds.value.includes(id)
}
function videoFailMessage(id) {
@@ -2132,6 +2135,7 @@ function toggleVideoSelectMode() {
if (!videoSelectMode.value) selectedVideoSbIds.value = []
}
function onVideoTaskRowClick(sb) {
if (continuityDirty.value && selectedSb.value?.id !== sb.id) { toast.error('请先保存当前镜头的衔接设置'); return }
if (videoSelectMode.value) toggleVideoSbSelect(sb.id)
else selectedSb.value = sb
}
@@ -2275,6 +2279,7 @@ function videoTaskRoutingLabel(task, compact = false) {
}
function canReplaceVideo(sb) {
const task = latestVideoTask(sb?.id)
if (task?.status === 'processing' && ['waiting_predecessor', 'blocked_predecessor'].includes(task.stage)) return true
return task?.status === 'processing' && !!task.task_id && ['minimax', 'localvideo'].includes(task.provider)
&& !task.result_url && !['submitting', 'transferring', 'downloading', 'saving'].includes(task.stage)
}
@@ -2305,6 +2310,7 @@ function openBatchVideoConfirm(pool) {
batchVideoConfirm.value = { open: true, targets }
}
function batchVideos() {
if (continuityDirty.value) { toast.error('请先保存当前镜头的衔接设置'); return }
// 选择模式且有勾选 → 仅所选(允许重出已完成镜头);否则全部未完成(待生成+失败)
const useSelection = videoSelectMode.value && selectedVideoSbIds.value.length
const pool = useSelection
@@ -2315,12 +2321,12 @@ function batchVideos() {
async function retryFailedVideos() {
await Promise.allSettled(sbs.value.filter(sb => canRecoverVideo(sb.id)).map(recoverVideo))
}
function confirmBatchVideos() {
const targets = [...batchVideoConfirm.value.targets]
async function confirmBatchVideos() {
const targets = [...batchVideoConfirm.value.targets].sort((a, b) => (a.storyboard_number || a.storyboardNumber) - (b.storyboard_number || b.storyboardNumber))
batchVideoConfirm.value = { open: false, targets: [] }
if (!targets.length) return
const ids = targets.map(s => s.id)
targets.forEach(sb => genVid(sb, { silent: true, batch: true }))
for (const sb of targets) await genVid(sb, { silent: true, batch: true })
toast.success(t('episode.vid.batchStarted', { n: ids.length }))
watchAsyncResult(() => ids.every(id => {
const target = sbs.value.find(s => s.id === id)
@@ -2465,6 +2471,8 @@ function genTaskKindLabel(kind) {
}
function trackedStageLabel(stage) {
if (stage === 'waiting_predecessor') return '等待前镜头'
if (stage === 'blocked_predecessor') return '前镜头待处理'
return ['queued', 'running', 'checking', 'reconnecting'].includes(stage) ? t('taskTracking.' + stage) : ''
}
function genTaskStatusLabel(status, stage, errorCode) {
@@ -3456,6 +3464,7 @@ function uploadAssetImage(kind, id) {
}
async function genVid(sb, opts = {}) {
if (continuityDirty.value) { toast.error('请先保存当前镜头的衔接设置'); return }
if (videoSubmittingIds.value.includes(sb.id)) return
if (!opts.forceNew && canRecoverVideo(sb.id)) return recoverVideo(sb)
const latest = latestVideoTask(sb.id)
@@ -0,0 +1,20 @@
const {chromium}=require('playwright-core'),fs=require('node:fs'),assert=require('node:assert/strict');
(async()=>{
const root='D:/web/dj',base=JSON.parse(fs.readFileSync(root+'/huobao-data/last-launch.json')).api.replace('/api/v1','');
const browser=await chromium.launch({channel:'chrome',headless:true});
try{
const page=await browser.newPage({viewport:{width:1600,height:1100}}),errors=[];
page.on('pageerror',e=>errors.push(e.message));
await page.addInitScript(()=>localStorage.setItem('huobao:tours',JSON.stringify(['episode'])));
await page.route('**/api/**',r=>['GET','HEAD'].includes(r.request().method())?r.continue():r.abort());
await page.goto(base+'/drama/5/episode/1',{waitUntil:'networkidle'});
await page.getByText('视频制作',{exact:true}).first().click();
const panel=page.locator('.continuity-panel');await panel.waitFor();
assert.equal(await panel.getByLabel('衔接方式').inputValue(),'none');
assert.equal(await panel.getByRole('button',{name:'保存衔接',exact:true}).isDisabled(),true);
await panel.scrollIntoViewIfNeeded();
await page.screenshot({path:root+'/integration-work/continuity-installed.png'});
assert.deepEqual(errors,[]);
console.log('PASS: installed continuity panel visible; existing shots unchanged; save disabled without edits; no writes or runtime errors');
}finally{await browser.close()}
})().catch(e=>{console.error(e.stack);process.exit(1)});
@@ -0,0 +1,34 @@
const { chromium } = require('playwright-core');
const assert = require('node:assert/strict');
const path = require('node:path');
(async () => {
const browser = await chromium.launch({channel:'chrome',headless:true});
const errors=[];
try {
const page = await browser.newPage({viewport:{width:1600,height:1120}});
page.setDefaultTimeout(10000);
await page.addInitScript(()=>localStorage.setItem('huobao:tours',JSON.stringify(['episode'])));
page.on('pageerror',error=>{errors.push(error.message);console.log('UI ERROR',error.message)});
await page.route('**/api/v1/tasks',route=>route.request().method()==='POST'?route.abort():route.continue());
await page.goto('http://127.0.0.1:18930/drama/1/episode/2',{waitUntil:'networkidle'});
const tourClose=page.locator('.driver-popover-close-btn');
if(await tourClose.isVisible()) await tourClose.click();
await page.getByText('视频制作',{exact:true}).first().click();
const panel=page.locator('.continuity-panel');
await panel.waitFor();
assert.match(await panel.innerText(),/接第 1 集 · 分镜 2/);
await panel.getByLabel('本镜头开始状态').fill('右手持剑,位于画面左侧,向右继续行走');
await panel.getByRole('button',{name:'保存衔接',exact:true}).click();
await page.waitForFunction(()=>document.querySelector('.continuity-actions')?.textContent.includes('衔接设置已保存'));
await panel.getByRole('button',{name:'预览前镜头尾帧',exact:true}).click();
await page.waitForFunction(()=>{const img=document.querySelector('.continuity-panel img');return img&&img.complete&&img.naturalWidth>0});
const data=await page.evaluate(async()=> (await (await fetch('/api/v1/storyboards/3/continuity')).json()).data);
assert.match(data.startState,/右手持剑/);
await panel.scrollIntoViewIfNeeded();
await page.screenshot({path:path.join(process.env.CONTINUITY_FIXTURE,'continuity-ui.png')});
await page.setViewportSize({width:1280,height:850});
assert.equal(await panel.evaluate(e=>e.scrollWidth<=e.clientWidth+2),true);
assert.deepEqual(errors,[]);
console.log('PASS: cross-episode source label, state save, decoded tail-frame preview, narrow layout, no runtime errors; no video generation');
} finally { await browser.close(); }
})().catch(error=>{console.error(error.stack);process.exit(1)});
+130
View File
@@ -0,0 +1,130 @@
"""End-to-end continuity tests against an isolated SQLite DB and a loopback fake provider."""
import base64,json,os,pathlib,re,sqlite3,subprocess,threading,time,urllib.request,urllib.error
from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer
root=pathlib.Path(__file__).resolve().parent.parent
data=root/'integration-work'/('continuity-test-'+time.strftime('%Y%m%d-%H%M%S'));data.mkdir()
stage=root/'integration-work/stage-4.0.11/app'
dbfile=data/'huobao.sqlite3';posts=[];chat_posts=[];process=None
(data/'static/videos').mkdir(parents=True)
subprocess.run([str(stage/'resources/bin/ffmpeg.exe'),'-hide_banner','-loglevel','error','-f','lavfi','-i','color=c=blue:s=320x192:d=2','-f','lavfi','-i','anullsrc=r=32000:cl=stereo','-shortest','-c:v','libx264','-c:a','aac',str(data/'static/videos/source.mp4')],check=True)
source=(root/'huobao-source/desktop/build/backend.mjs').read_text('utf-8')
source=source.replace('const require = __hbCreateRequire(import.meta.url)', 'const require = __hbCreateRequire('+json.dumps(str(stage/'resources/app.asar/build/backend.mjs'))+')',1)
source=re.sub(r'^import (\w+) from "(better-sqlite3|sharp)";',r'const \1 = require("\2");',source,flags=re.M)
# Replace only the local-provider capability check in this private test copy.
needle='u.hostname === "192.168.110.111" && ["18792", "18794"].includes(u.port)'
assert source.count(needle)==1
source=source.replace(needle,'u.hostname === "127.0.0.1" && u.port === "18931"')
(data/'backend.mjs').write_text(source,'utf-8')
class Mock(BaseHTTPRequestHandler):
def log_message(self,*args):pass
def do_POST(self):
body=json.loads(self.rfile.read(int(self.headers['Content-Length'])))
if self.path.endswith('/chat/completions'):
chat_posts.append(body)
if len(chat_posts)==1:
tool=next(t['function']['name'] for t in body['tools'] if 'storyboard' in t['function']['name'].lower() and ('context' in t['function']['name'].lower()))
message={'role':'assistant','content':None,'tool_calls':[{'id':'call_fixture_context','type':'function','function':{'name':tool,'arguments':'{}'}}]};reason='tool_calls'
else:message={'role':'assistant','content':'检查完成'};reason='stop'
raw=json.dumps({'id':'fixture-chat','object':'chat.completion','created':1,'model':'fixture-text','choices':[{'index':0,'message':message,'finish_reason':reason}],'usage':{'prompt_tokens':20,'completion_tokens':20,'total_tokens':40}}).encode()
self.send_response(200);self.send_header('Content-Type','application/json');self.end_headers();self.wfile.write(raw);return
posts.append(body)
raw=json.dumps({'task_id':str(len(posts)),'status':'succeeded','video_url':'http://127.0.0.1:18931/video.mp4','duration':2}).encode()
self.send_response(200);self.send_header('Content-Type','application/json');self.end_headers();self.wfile.write(raw)
def do_GET(self):
if self.path!='/video.mp4':self.send_error(404);return
self.send_response(200);self.send_header('Content-Type','video/mp4');self.end_headers();self.wfile.write((data/'static/videos/source.mp4').read_bytes())
server=ThreadingHTTPServer(('127.0.0.1',18931),Mock);threading.Thread(target=server.serve_forever,daemon=True).start()
env={**os.environ,'ELECTRON_RUN_AS_NODE':'1','PORT':'18930','HUOBAO_DATA_DIR':str(data),'SQLITE_PATH':str(dbfile),'FRONTEND_DIST':str(root/'huobao-source/frontend/.output/public'),'WORKSPACE_PATH':str(stage/'resources/workspace-template'),'FFMPEG_BIN':str(stage/'resources/bin/ffmpeg.exe'),'FFPROBE_BIN':str(stage/'resources/bin/ffprobe.exe')}
log=(data/'backend.log').open('w',encoding='utf-8')
def api(method,path,body=None,expect=200):
req=urllib.request.Request('http://127.0.0.1:18930/api/v1'+path,data=None if body is None else json.dumps(body).encode(),method=method,headers={'Content-Type':'application/json'})
try:
with urllib.request.urlopen(req,timeout=40) as response:code=response.status;payload=json.load(response)
except urllib.error.HTTPError as e:code=e.code;payload=json.load(e)
assert code==expect,(method,path,code,payload)
return payload.get('data',payload)
def start():
global process
process=subprocess.Popen([str(stage/'HuobaoDrama.exe'),str(data/'backend.mjs')],cwd=data,env=env,stdout=log,stderr=log,creationflags=subprocess.CREATE_NO_WINDOW)
for _ in range(150):
assert process.poll() is None,'Backend exited; inspect '+str(data/'backend.log')
try:api('GET','/health');return
except (OSError,AssertionError):time.sleep(.1)
raise AssertionError('Backend not ready')
def stop():
process.terminate();process.wait(timeout=10)
def sql(statement,args=()):
with sqlite3.connect(dbfile) as db:
db.row_factory=sqlite3.Row
return [dict(r) for r in db.execute(statement,args).fetchall()]
def wait_task(id,stage=None,status=None):
for _ in range(300):
row=sql('SELECT * FROM sys_task WHERE id=?',(id,))[0]
if (stage is None or row['stage']==stage) and (status is None or row['status']==status):return row
time.sleep(.1)
raise AssertionError(row)
def settings(id,mode,source=None,expect=200,**extra):return api('PUT',f'/storyboards/{id}/continuity',{'continuity_mode':mode,'continuity_source_id':source,**extra},expect)
def generate(id,**extra):return api('POST','/tasks',{'type':'video','storyboard_id':id,'config_id':1,'model':'MiniMax-H3','prompt':'@图片1人物向右迈步,无新增台词。','duration':4,'gpu':'gpu3',**extra},201)['id']
try:
start()
ts='2026-11-12T00:00:00Z'
with sqlite3.connect(dbfile) as db:
for id in [1,2]:db.execute('INSERT INTO dramas(id,title,created_at,updated_at) VALUES(?,?,?,?)',(id,'isolated',ts,ts))
for id,drama,num,deleted in [(1,1,1,None),(2,1,2,None),(3,2,1,None),(4,1,0,ts)]:db.execute('INSERT INTO episodes(id,drama_id,episode_number,title,content,created_at,updated_at,deleted_at) VALUES(?,?,?,?,?,?,?,?)',(id,drama,num,'test','前集结尾',ts,ts,deleted))
db.execute('INSERT INTO ai_service_configs(id,service_type,provider,name,base_url,api_key,model,is_active,created_at,updated_at) VALUES(1,?,?,?,?,?,?,1,?,?)',('video','minimax','fixture','http://127.0.0.1:18931','test-only-key',json.dumps(['MiniMax-H3']),ts,ts))
db.execute('INSERT INTO ai_service_configs(id,service_type,provider,name,base_url,api_key,model,is_active,created_at,updated_at) VALUES(2,?,?,?,?,?,?,1,?,?)',('text','openai','fixture-text','http://127.0.0.1:18931/v1','test-only-key',json.dumps(['fixture-text']),ts,ts))
for id,ep,num,scene in [(1,1,1,1),(2,1,2,1),(3,2,1,1),(4,2,2,1),(5,2,3,2),(6,3,1,1),(7,4,1,1),(8,2,4,1),(9,2,5,1)]:
db.execute('INSERT INTO storyboards(id,episode_id,storyboard_number,scene_id,description,duration,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)',(id,ep,num,scene,'动作描述',4,ts,ts))
# Dependency validation across episodes, scenes, and projects.
assert settings(3,'context')['source']['id']==2
api('POST','/agent/storyboard_breaker/chat',{'drama_id':1,'episode_id':2,'config_id':2,'message':'先读取分镜上下文,核对上一集结尾,不修改任何内容。'})
assert len(chat_posts)==2
contexts=[m for m in chat_posts[-1]['messages'] if m.get('role')=='tool']
text=json.dumps(contexts,ensure_ascii=False)
assert 'previous_episode' in text and '前集结尾' in text and 'ending_storyboards' in text
assert '跨镜头与跨集衔接' in json.dumps(chat_posts[0],ensure_ascii=False)
settings(3,'first_frame',4,expect=400);settings(3,'first_frame',6,expect=400);settings(3,'first_frame',3,expect=400)
settings(3,'first_frame',7,expect=400);settings(5,'first_frame',4,expect=400)
settings(1,'context',expect=400)
settings(3,'first_frame',2,continuity_start='右手持剑,朝向右侧',continuity_end='向右跨步结束')
api('POST','/tasks',{'type':'video','storyboard_id':3,'config_id':1,'model':'MiniMax-H3-VDN-H3','prompt':'test'},400)
task=generate(3);wait_task(task,stage='waiting_predecessor');assert not posts
api('POST','/tasks',{'type':'video','storyboard_id':3,'config_id':1,'prompt':'duplicate'},400)
# Independent shots still run while this shot waits.
independent=generate(5);wait_task(independent,status='completed');assert len(posts)==1
# Pending task survives restart, without resubmission.
stop();start();wait_task(task,stage='waiting_predecessor');assert len(posts)==1
sql('UPDATE storyboards SET video_url=? WHERE id=2',('static/videos/source.mp4',))
result=wait_task(task,status='completed');assert len(posts)==2
sent=posts[-1];images=[x for x in sent['content'] if x['type']=='image_url']
assert len(images)==1 and images[0]['role']=='first_frame' and images[0]['image_url']['url'].startswith('data:image/')
assert sent['gpu']=='gpu3' and '@图片1' not in sent['content'][0]['text'] and '禁止朗读' in sent['content'][0]['text']
params=json.loads(result['params']);assert params['continuity']['sourceId']==2 and params['continuity']['frameUrl']
assert api('GET','/storyboards/3/continuity')['stale'] is False
# Changing previous video flags the existing downstream output without replacing it.
sql('UPDATE storyboards SET video_url=? WHERE id=2',('static/videos/changed.mp4',))
assert api('GET','/storyboards/3/continuity')['stale'] is True
assert sql('SELECT video_url FROM storyboards WHERE id=3')[0]['video_url']==result['local_path']
# Failure blocks dependents; cancellation is local and never submits media.
settings(4,'first_frame',3)
sql('INSERT INTO sys_task(type,storyboard_id,status,error_code,created_at,updated_at) VALUES(?,?,?,?,?,?)',('video',3,'failed','generation_failed',ts,ts))
blocked=generate(4);wait_task(blocked,stage='blocked_predecessor');count=len(posts)
api('POST',f'/tasks/{blocked}/cancel-waiting',{});wait_task(blocked,status='failed');assert len(posts)==count
# Replacing a waiting task affects only that task and persists the new GPU.
settings(8,'first_frame',3);old=generate(8);wait_task(old,stage='blocked_predecessor')
new=generate(8,replace_task_id=old,force_new=True,gpu='gpu2');wait_task(new,stage='blocked_predecessor')
assert sql('SELECT error_code FROM sys_task WHERE id=?',(old,))[0]['error_code']=='user_cancelled'
assert json.loads(sql('SELECT params FROM sys_task WHERE id=?',(new,))[0]['params'])['gpu']=='gpu2'
api('POST',f'/tasks/{new}/cancel-waiting',{})
assert len(posts)==count
# A deleted source can be detached without losing this shot or its output.
sql('UPDATE storyboards SET deleted_at=? WHERE id=3',(ts,))
detached=settings(8,'none',3)
assert detached['mode']=='none' and detached['source'] is None
print(json.dumps({'passed':True,'fixture':str(data),'provider_submissions':len(posts),'agent_received_previous_episode':True,'waiting_restart':True,'cross_episode':True,'dependency_validation':True,'real_tail_frame_extracted':True,'single_first_frame':True,'gpu_preserved':True,'stale_warning':True,'cancel_and_replace_waiting':True}))
(data/'result.json').write_text(json.dumps({'passed':True,'provider_submissions':len(posts)}))
(root/'integration-work/continuity-test-current.txt').write_text(str(data))
finally:
if process and process.poll() is None:stop()
server.shutdown();log.close()
+19
View File
@@ -0,0 +1,19 @@
import json,os,pathlib,sqlite3,subprocess,time,urllib.request
root=pathlib.Path(__file__).resolve().parent.parent
data=pathlib.Path((root/'integration-work/continuity-test-current.txt').read_text())
stage=root/'integration-work/stage-4.0.11/app'
with sqlite3.connect(data/'huobao.sqlite3') as db:
db.execute("UPDATE storyboards SET video_url='static/videos/source.mp4' WHERE id=2")
db.execute("UPDATE storyboards SET continuity_start='右手持剑,朝向右侧' WHERE id=3")
db.execute("UPDATE storyboards SET deleted_at=NULL WHERE id=3")
db.execute("UPDATE episodes SET script_content='隔离测试剧本:人物持剑向右走。' WHERE id=2")
env={**os.environ,'ELECTRON_RUN_AS_NODE':'1','PORT':'18930','HUOBAO_DATA_DIR':str(data),'SQLITE_PATH':str(data/'huobao.sqlite3'),'FRONTEND_DIST':str(root/'huobao-source/frontend/.output/public'),'WORKSPACE_PATH':str(stage/'resources/workspace-template'),'FFMPEG_BIN':str(stage/'resources/bin/ffmpeg.exe'),'FFPROBE_BIN':str(stage/'resources/bin/ffprobe.exe')}
log=(data/'ui-server.log').open('w',encoding='utf-8');process=subprocess.Popen([str(stage/'HuobaoDrama.exe'),str(data/'backend.mjs')],cwd=data,env=env,stdout=log,stderr=log,creationflags=subprocess.CREATE_NO_WINDOW)
try:
for _ in range(100):
try:
urllib.request.urlopen('http://127.0.0.1:18930/api/v1/health',timeout=1);break
except OSError:time.sleep(.1)
subprocess.run(['node',str(root/'integration-work/browser-test/verify_continuity_ui.cjs')],cwd=root,env={**os.environ,'CONTINUITY_FIXTURE':str(data)},check=True)
print('Screenshot:',data/'continuity-ui.png')
finally:process.terminate();process.wait(timeout=10);log.close()
+1 -1
View File
@@ -28,7 +28,7 @@ archive.write_bytes(struct.pack('<4I',4,size,size-4,len(encoded))+encoded+paddin
frontend=root/'huobao-source/frontend/.output/public'
shutil.copytree(frontend,stage/'resources/frontend',dirs_exist_ok=True)
manifest={'upstream_version':'4.0.11','source_commit':'b48e6b2312166e25b1ff141832d2e82e5d63c142',
'custom_features':['persistent-data','local-video-pool','multi-gpu-images','vdn-hd-versions','per-model-gpu-selection','fixed-gpu-models','cancel-and-regenerate','per-shot-routing','direct-comfy-panels','confirmed-worker-labels','asset-image-references','durable-local-task-tracking'],
'custom_features':['persistent-data','local-video-pool','multi-gpu-images','vdn-hd-versions','per-model-gpu-selection','fixed-gpu-models','cancel-and-regenerate','per-shot-routing','direct-comfy-panels','confirmed-worker-labels','asset-image-references','durable-local-task-tracking','cross-episode-continuity','tail-frame-dependency-queue'],
'archive_sha256':hashlib.sha256(archive.read_bytes()).hexdigest()}
(stage/'resources/huobao-local-build.json').write_text(json.dumps(manifest,indent=2))
print('Staged 4.0.11 with persistent data and VDN HD:',manifest['archive_sha256'])