diff --git a/.gitignore b/.gitignore index 6aacc3a..e168818 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md index 786358e..43fb6da 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,21 @@ - 角色、场景、道具的图片参考图上传与编辑接口。 - 本地长任务持续查询、重启后恢复查询和原任务结果恢复。 - 固定数据目录、一键启动、升级备份与真人实拍、仙侠风格预设。 +- 跨集结尾上下文、分镜起止状态、前镜头尾帧接续与持久化依赖队列。 + +## 镜头衔接 + +视频制作中、视频提示词上方的「镜头衔接」面板提供三种模式: + +- **独立镜头 / 转场**:不依赖前段,适合换场景、时间跳跃或开场。 +- **状态承接**:将前镜头结束状态与本镜头起止状态加入生成约束;允许换角度,仍使用本镜头的资产参考图。 +- **尾帧接续**:同场景连续动作。等待前段完成,从所选视频结束前 0.05–3 秒提取画面作为单张首帧,再向选定 GPU 提交。支持当前本地 MiniMax-H3、MiniMax-H3-Hybrid、Wan2.2-I2V;不与多张参考图或参考音色混用。 + +来源默认为按集号、镜号排序的前一镜,也可选择本项目更早的镜头。不能引用未来、自身、其他项目或已删除的镜头。第一镜、转场需用独立模式。拆分镜工具会读取上一集结尾剧本及最后三个分镜;起止状态是文字设定,不代表系统已经识别或审核了成片画面。 + +已有分镜升级后保持原模式、原视频,不会自动重新生成。新拆分时 Agent 会填写起止状态并选择衔接方式;旧分镜可在面板中补填后保存。尾帧预览仅提取画面,不生成新视频。前镜头失败时下游保持等待;可取消等待,或先处理前镜头。等待中的任务可重启恢复,并保留原模型与显卡选择。 + +前镜头版本或起止设定改变后,已生成的下游视频显示核对提示,旧版保留,不自动重跑。依赖链按顺序提交,独立镜头仍可多卡并行。VDN-H3 高清制作仍使用其参考图工作流,不支持固定首帧接续。 ## 目录 diff --git a/huobao-source/backend/src/agents/index.ts b/huobao-source/backend/src/agents/index.ts index 27dbcfb..279711f 100644 --- a/huobao-source/backend/src/agents/index.ts +++ b/huobao-source/backend/src/agents/index.ts @@ -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') } diff --git a/huobao-source/backend/src/agents/tools/storyboard-tools.ts b/huobao-source/backend/src/agents/tools/storyboard-tools.ts index d0ed29a..1c7b5aa 100644 --- a/huobao-source/backend/src/agents/tools/storyboard-tools.ts +++ b/huobao-source/backend/src/agents/tools/storyboard-tools.ts @@ -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, }) diff --git a/huobao-source/backend/src/db/schema.ts b/huobao-source/backend/src/db/schema.ts index ef61246..a477e92 100644 --- a/huobao-source/backend/src/db/schema.ts +++ b/huobao-source/backend/src/db/schema.ts @@ -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'), diff --git a/huobao-source/backend/src/db/sqlite-schema.ts b/huobao-source/backend/src/db/sqlite-schema.ts index 108fa62..448ec48 100644 --- a/huobao-source/backend/src/db/sqlite-schema.ts +++ b/huobao-source/backend/src/db/sqlite-schema.ts @@ -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)) diff --git a/huobao-source/backend/src/routes/storyboards.ts b/huobao-source/backend/src/routes/storyboards.ts index a690c51..ffda087 100644 --- a/huobao-source/backend/src/routes/storyboards.ts +++ b/huobao-source/backend/src/routes/storyboards.ts @@ -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) diff --git a/huobao-source/backend/src/routes/tasks.ts b/huobao-source/backend/src/routes/tasks.ts index 3d26834..7041940 100644 --- a/huobao-source/backend/src/routes/tasks.ts +++ b/huobao-source/backend/src/routes/tasks.ts @@ -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'))) diff --git a/huobao-source/backend/src/services/continuity.ts b/huobao-source/backend/src/services/continuity.ts new file mode 100644 index 0000000..a22dd3e --- /dev/null +++ b/huobao-source/backend/src/services/continuity.ts @@ -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 = {}) { + 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 { + const result: Partial = {} + 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>() +export async function extractContinuityFrame(video: string, offsetMs: number): Promise { + 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((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((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 } +} diff --git a/huobao-source/backend/src/services/generation.ts b/huobao-source/backend/src/services/generation.ts index dc59f5d..829cf0b 100644 --- a/huobao-source/backend/src/services/generation.ts +++ b/huobao-source/backend/src/services/generation.ts @@ -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() @@ -131,11 +133,22 @@ export async function generateVideo(params: GenerateVideoParams): Promise { } } +async function prepareContinuityTask(record: SysTaskRecord): Promise { + 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 { diff --git a/huobao-source/frontend/app/components/ShotContinuity.vue b/huobao-source/frontend/app/components/ShotContinuity.vue new file mode 100644 index 0000000..d600d82 --- /dev/null +++ b/huobao-source/frontend/app/components/ShotContinuity.vue @@ -0,0 +1,99 @@ +