Add per-task image and video cancellation with confirmed worker receipts
This commit is contained in:
@@ -9,6 +9,7 @@
|
||||
- MiniMax H3 视频及 VDN-H3 2K/4K 高清版本,保留原版供对比。
|
||||
- 角色、场景、道具的图片参考图上传与编辑接口。
|
||||
- 本地长任务持续查询、重启后恢复查询和原任务结果恢复。
|
||||
- 任务列表可单独取消排队或生成中的本地图片、视频任务,保留已有素材和历史记录。
|
||||
- 固定数据目录、一键启动、升级备份与真人实拍、仙侠风格预设。
|
||||
- 跨集结尾上下文、分镜起止状态、前镜头尾帧接续与持久化依赖队列。
|
||||
|
||||
@@ -34,6 +35,12 @@
|
||||
|
||||
服务器的进度扩展源文件为 `integration-work/comfy_progress.py`,由 `install_comfy_progress.py` 备份后嵌入现有适配器;无需重启 ComfyUI。图片、视频及私有配置仍不进入 Git。
|
||||
|
||||
## 取消任务
|
||||
|
||||
打开右上角「任务」,在对应图片或视频记录上点击「取消任务」。只停止这一条任务,不影响其他镜头、其他显卡任务或已有素材。等待前镜头的任务也可以直接取消。
|
||||
|
||||
已提交的任务需要收到对应节点的取消确认,才会显示「已取消」;未收到回执时显示「正在等待显卡确认」,可重试取消,重启后仍会跟踪原任务。提交中尚无任务编号、已经完成或正在保存结果的任务不提供取消按钮。已取消任务单独统计,不计入失败数量。
|
||||
|
||||
## 目录
|
||||
|
||||
| 目录或文件 | 用途 |
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Hono } from 'hono'
|
||||
import { taskRouting } from '../services/worker-routing.js'
|
||||
import { canCancelGenerationTask } from '../services/generation.js'
|
||||
import { and, eq, isNull } from 'drizzle-orm'
|
||||
import { db, getInsertId, schema } from '../db/index.js'
|
||||
import { success, notFound, badRequest, now } from '../utils/response.js'
|
||||
@@ -260,7 +261,7 @@ app.get('/:id/generation-tasks', async (c) => {
|
||||
.slice(0, 20)
|
||||
|
||||
return success(c, {
|
||||
tasks: toSnakeCaseArray(tasks.map(task => ({ ...task, routing: taskRouting(task) }))),
|
||||
tasks: toSnakeCaseArray(tasks.map(task => ({ ...task, routing: taskRouting(task), canCancel: canCancelGenerationTask(task) }))),
|
||||
merges: toSnakeCaseArray(merges),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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, cancelWaitingVideo } from '../services/generation.js'
|
||||
import { generateImage, generateVideo, recoverGenerationTask, cancelWaitingVideo, cancelGenerationTask, canCancelGenerationTask } 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', async c => {
|
||||
try { return success(c, await cancelGenerationTask(Number(c.req.param('id')))) }
|
||||
catch (error: any) { return badRequest(c, safeTaskError(error)) }
|
||||
})
|
||||
|
||||
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) }
|
||||
@@ -253,7 +258,7 @@ app.get('/:id', async (c) => {
|
||||
const id = Number(c.req.param('id'))
|
||||
const [row] = await db.select().from(schema.sysTask)
|
||||
.where(eq(schema.sysTask.id, id))
|
||||
return success(c, row ? { ...row, routing: taskRouting(row) } : null)
|
||||
return success(c, row ? { ...row, routing: taskRouting(row), canCancel: canCancelGenerationTask(row) } : null)
|
||||
})
|
||||
|
||||
// GET /tasks — 按 type / storyboard_id / drama_id 过滤
|
||||
@@ -268,7 +273,7 @@ app.get('/', async (c) => {
|
||||
if (storyboardId) rows = rows.filter(r => r.storyboardId === Number(storyboardId))
|
||||
if (dramaId) rows = rows.filter(r => r.dramaId === Number(dramaId))
|
||||
|
||||
return success(c, rows.map(row => ({ ...row, routing: taskRouting(row) })))
|
||||
return success(c, rows.map(row => ({ ...row, routing: taskRouting(row), canCancel: canCancelGenerationTask(row) })))
|
||||
})
|
||||
|
||||
// DELETE /tasks/:id
|
||||
|
||||
@@ -150,7 +150,7 @@ export class MiniMaxVideoAdapter implements VideoProviderAdapter {
|
||||
const err = task.error
|
||||
const msg = typeof err === 'string' ? err : (err?.message || JSON.stringify(err) || 'Video generation failed')
|
||||
const code = err && typeof err === 'object' && err.code ? `[${err.code}] ` : ''
|
||||
return { status: 'failed', error: `${code}${msg}` }
|
||||
return { status: 'failed', error: `${code}${msg}`, cancelled: status === 'cancelled' }
|
||||
}
|
||||
const workerStage = ['queued', 'running', 'checking', 'missing'].includes(task.worker_state) ? task.worker_state : undefined
|
||||
return { status: 'processing', stage: task.delivery?.status === 'transferring' ? 'transferring' : workerStage, routing: parseWorkerRouting(task.routing), progress: parseTaskProgress(task.progress) }
|
||||
|
||||
@@ -175,7 +175,7 @@ export class OpenAIImageAdapter implements ImageProviderAdapter {
|
||||
}
|
||||
}
|
||||
if (result.status === 'failed') {
|
||||
return { status: 'failed', error: result.error?.message || 'Generation failed' }
|
||||
return { status: 'failed', error: result.error?.message || 'Generation failed', cancelled: result.cancelled === true }
|
||||
}
|
||||
return { status: result.status || 'processing', stage: ['queued', 'running', 'checking', 'missing'].includes(result.worker_state) ? result.worker_state : undefined }
|
||||
}
|
||||
|
||||
@@ -123,6 +123,7 @@ export interface ImageGenResponse {
|
||||
}
|
||||
|
||||
export interface ImagePollResponse {
|
||||
cancelled?: boolean
|
||||
stage?: 'queued' | 'running' | 'checking' | 'missing'
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed'
|
||||
imageUrl?: string
|
||||
@@ -138,6 +139,7 @@ export interface VideoGenResponse {
|
||||
}
|
||||
|
||||
export interface VideoPollResponse {
|
||||
cancelled?: boolean
|
||||
progress?: import('../task-progress').TaskProgress | null
|
||||
routing?: import('../worker-routing').WorkerRouting | null
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed'
|
||||
|
||||
@@ -11,6 +11,7 @@ import { prepareReferenceAudio, resolveReferenceAudioUrls } from './reference-au
|
||||
import { getActiveConfig, getConfigById } from './ai.js'
|
||||
import { parseWorkerRouting, type WorkerRouting } from './worker-routing.js'
|
||||
import { isLocalQueuedTask, taskPollingProfile } from './local-task-tracking.js'
|
||||
import { joinProviderUrl } from './adapters/url'
|
||||
import { now } from '../utils/response.js'
|
||||
import { downloadFile, fetchImageAsCompressedDataUrl, generateImageThumb, readImageAsCompressedDataUrl, readImageAsDataUrl, saveBase64Image } from '../utils/storage.js'
|
||||
import { extractVideoPoster } from '../utils/video-poster.js'
|
||||
@@ -223,7 +224,7 @@ 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',
|
||||
const result = await db.update(schema.sysTask).set({ status: 'failed', stage: 'cancelled', 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 })
|
||||
@@ -231,39 +232,77 @@ export async function cancelWaitingVideo(id: number) {
|
||||
}
|
||||
|
||||
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)
|
||||
if (!['minimax', 'localvideo'].includes(config.provider)) throw new Error('原模型不支持按任务取消')
|
||||
const base = config.baseUrl.replace(/\/$/, '')
|
||||
const headers = { Authorization: `Bearer ${config.apiKey}`, 'Content-Type': 'application/json' }
|
||||
cancellingTasks.add(record.id)
|
||||
const result = await cancelGenerationTask(record.id, 'replacement')
|
||||
if (result.outcome === 'completed') throw new Error('原视频已经生成,正在保存;完成后可以重新生成')
|
||||
if (result.outcome === 'pending') throw new Error('原任务仍在停止中,请稍后再点击;未提交新任务')
|
||||
}
|
||||
|
||||
export function canCancelGenerationTask(record: SysTaskRecord): boolean {
|
||||
if (record.status !== 'processing' || record.resultUrl || ['downloading', 'saving', 'transferring'].includes(record.stage || '')) return false
|
||||
if (record.type === 'video' && !record.taskId && ['waiting_predecessor', 'blocked_predecessor'].includes(record.stage || '')) return true
|
||||
return !!record.taskId && record.stage !== 'submitting'
|
||||
&& (record.type === 'video' ? ['minimax', 'localvideo'].includes(record.provider || '') : record.type === 'image' && record.provider === 'openai')
|
||||
&& isLocalQueuedTask({ baseUrl: record.configBaseUrl || '', provider: record.provider || '' }, record.taskId)
|
||||
}
|
||||
|
||||
async function markTaskCancelled(id: number, reason = 'user') {
|
||||
await db.update(schema.sysTask).set({ status: 'failed', stage: 'cancelled', errorCode: 'user_cancelled',
|
||||
errorMsg: reason === 'replacement' ? '已取消原任务并请求重新生成,历史记录保留' : '任务已取消,历史记录和已有素材保留', updatedAt: now(), completedAt: now() })
|
||||
.where(and(eq(schema.sysTask.id, id), eq(schema.sysTask.status, 'processing')))
|
||||
}
|
||||
|
||||
export async function cancelGenerationTask(id: number, reason: 'user' | 'replacement' = 'user'): Promise<{ outcome: 'cancelled' | 'pending' | 'completed' | 'failed' }> {
|
||||
if (!Number.isSafeInteger(id) || id <= 0) throw new Error('任务编号无效')
|
||||
const record = await db.select().from(schema.sysTask).where(eq(schema.sysTask.id, id)).get()
|
||||
if (!record) throw new Error('任务不存在')
|
||||
if (record.errorCode === 'user_cancelled') return { outcome: 'cancelled' }
|
||||
if (record.status !== 'processing') throw new Error('任务已结束,请刷新列表')
|
||||
if (cancellingTasks.has(id)) return { outcome: 'pending' }
|
||||
if (!canCancelGenerationTask(record)) throw new Error('当前任务尚未返回编号、正在保存或不支持取消,请稍后刷新')
|
||||
if (!record.taskId) { await cancelWaitingVideo(id); return { outcome: 'cancelled' } }
|
||||
cancellingTasks.add(id)
|
||||
try {
|
||||
const config = await recoveryConfig(record)
|
||||
const current = await db.select().from(schema.sysTask).where(eq(schema.sysTask.id, id)).get()
|
||||
if (!current || !canCancelGenerationTask(current)) throw new Error('任务状态已变化,请刷新后操作')
|
||||
await db.update(schema.sysTask).set({ stage: 'cancelling', params: JSON.stringify({ ...parseTaskParams(current.params),
|
||||
cancellation: { requestedAt: now(), reason } }), updatedAt: now() })
|
||||
.where(and(eq(schema.sysTask.id, id), eq(schema.sysTask.status, 'processing')))
|
||||
const headers = { Authorization: `Bearer ${config.apiKey}`, 'Content-Type': 'application/json' }
|
||||
const request = async (cancel: boolean) => {
|
||||
const path = cancel ? `/v2/video_generation/${encodeURIComponent(record.taskId!)}/cancel` : `/v2/query/video_generation/${encodeURIComponent(record.taskId!)}`
|
||||
const response = await fetch(base + path, { method: cancel ? 'POST' : 'GET', headers,
|
||||
const job = encodeURIComponent(record.taskId!)
|
||||
const path = record.type === 'image' ? `/images/task/${job}${cancel ? '/cancel' : ''}`
|
||||
: cancel ? `/video_generation/${job}/cancel` : `/query/video_generation/${job}`
|
||||
const response = await fetch(joinProviderUrl(config.baseUrl, record.type === 'image' ? '/v1' : '/v2', path), { method: cancel ? 'POST' : 'GET', headers,
|
||||
...(cancel ? { body: '{}' } : {}), signal: AbortSignal.timeout(35_000) })
|
||||
if (!response.ok) throw new Error('原任务取消未确认,请稍后重试;未提交新任务')
|
||||
if (!response.ok) throw new Error('任务取消未确认,请稍后重试')
|
||||
const result = await response.json() as any
|
||||
if (result.task?.id !== record.taskId) throw new Error('取消回执任务编号不匹配;未提交新任务')
|
||||
return result.task.status as string
|
||||
const receipt = record.type === 'image' && !cancel ? { id: result.task_id,
|
||||
status: result.cancelled ? 'cancelled' : result.status === 'completed' ? 'succeeded' : result.status } : result.task
|
||||
if (receipt?.id !== record.taskId) throw new Error('取消回执任务编号不匹配')
|
||||
return receipt.status as string
|
||||
}
|
||||
let status = await request(true)
|
||||
let status: string
|
||||
const deadline = Date.now() + 45_000
|
||||
while (!['cancelled', 'failed', 'succeeded'].includes(status) && Date.now() < deadline) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
status = await request(false)
|
||||
try {
|
||||
status = await request(true)
|
||||
while (!['cancelled', 'failed', 'succeeded'].includes(status) && Date.now() < deadline) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
status = await request(false)
|
||||
}
|
||||
} catch {
|
||||
// The request may have reached the worker. Keep tracking the same ID;
|
||||
// only an exact terminal receipt can declare it cancelled.
|
||||
return { outcome: 'pending' }
|
||||
}
|
||||
if (status === 'succeeded') throw new Error('原视频已经生成,正在保存;完成后可以重新生成')
|
||||
if (!['cancelled', 'failed'].includes(status)) throw new Error('原任务仍在停止中,请稍后再点击;未提交新任务')
|
||||
await db.update(schema.sysTask).set({ status: 'failed', stage: 'failed', errorCode: 'user_cancelled',
|
||||
errorMsg: '已取消原任务并请求重新生成,历史记录保留', updatedAt: now() })
|
||||
.where(and(eq(schema.sysTask.id, record.id), eq(schema.sysTask.status, 'processing')))
|
||||
} finally { cancellingTasks.delete(record.id) }
|
||||
if (status === 'cancelled') { await markTaskCancelled(id, reason); return { outcome: 'cancelled' } }
|
||||
if (status === 'failed') { await failTask(id, '生成节点确认任务已失败', 'generation_failed'); return { outcome: 'failed' } }
|
||||
if (status === 'succeeded') {
|
||||
await db.update(schema.sysTask).set({ stage: 'polling', updatedAt: now() }).where(and(eq(schema.sysTask.id, id), eq(schema.sysTask.status, 'processing')))
|
||||
return { outcome: 'completed' }
|
||||
}
|
||||
return { outcome: 'pending' }
|
||||
} finally { cancellingTasks.delete(id) }
|
||||
}
|
||||
|
||||
async function createTask(
|
||||
@@ -553,6 +592,7 @@ async function pollTask(record: SysTaskRecord, config: AIConfig, taskId: string)
|
||||
if (cancellingTasks.has(record.id)) continue
|
||||
const [afterPoll] = await db.select().from(schema.sysTask).where(eq(schema.sysTask.id, record.id))
|
||||
if (!afterPoll || afterPoll.status !== 'processing') return
|
||||
if (cancellingTasks.has(record.id)) continue
|
||||
|
||||
// 图片/视频 PollResponse 结构不同,这里统一按 any 取值后按 type 分支
|
||||
const pollResp: any = adapter.parsePollResponse(result)
|
||||
@@ -563,7 +603,7 @@ async function pollTask(record: SysTaskRecord, config: AIConfig, taskId: string)
|
||||
await failTask(record.id, '显卡节点已找不到此任务,请核对节点重启记录后重新生成', 'upstream_missing')
|
||||
return
|
||||
}
|
||||
const stage = pollResp.stage === 'missing' ? 'checking' : pollResp.stage || 'polling'
|
||||
const stage = parseTaskParams(afterPoll.params).cancellation ? 'cancelling' : pollResp.stage === 'missing' ? 'checking' : pollResp.stage || 'polling'
|
||||
await db.update(schema.sysTask).set({ stage, updatedAt: now() })
|
||||
.where(and(eq(schema.sysTask.id, record.id), eq(schema.sysTask.status, 'processing')))
|
||||
}
|
||||
@@ -582,7 +622,7 @@ async function pollTask(record: SysTaskRecord, config: AIConfig, taskId: string)
|
||||
if (type === 'image') {
|
||||
if (pollResp.imageUrl) {
|
||||
logTaskSuccess(label, 'poll-complete', { id: record.id, taskId, imageUrl: pollResp.imageUrl })
|
||||
await handleImageComplete(record, pollResp.imageUrl)
|
||||
if (await handleImageComplete(record, pollResp.imageUrl) === false) continue
|
||||
return
|
||||
}
|
||||
if (adapter.provider === 'gemini') {
|
||||
@@ -596,11 +636,12 @@ async function pollTask(record: SysTaskRecord, config: AIConfig, taskId: string)
|
||||
}
|
||||
} else if (pollResp.videoUrl) {
|
||||
logTaskSuccess(label, 'poll-complete', { id: record.id, taskId, videoUrl: pollResp.videoUrl })
|
||||
await handleVideoComplete(record, pollResp.videoUrl, pollResp.duration)
|
||||
if (await handleVideoComplete(record, pollResp.videoUrl, pollResp.duration) === false) continue
|
||||
return
|
||||
}
|
||||
}
|
||||
if (pollResp.status === 'failed') {
|
||||
if (pollResp.cancelled) { await markTaskCancelled(record.id, parseTaskParams(afterPoll.params).cancellation?.reason); return }
|
||||
// 上游明确失败(如内容审核拦截)属终态:立即落库,不重试不等待超时
|
||||
const deliveryFailed = /video_transfer_failed/.test(pollResp.error || '')
|
||||
if (deliveryFailed) await db.update(schema.sysTask).set({ stage: 'transferring' }).where(eq(schema.sysTask.id, record.id))
|
||||
@@ -630,6 +671,11 @@ async function pollTask(record: SysTaskRecord, config: AIConfig, taskId: string)
|
||||
}
|
||||
|
||||
async function handleImageComplete(record: SysTaskRecord, imageUrl: string) {
|
||||
const current = await db.select().from(schema.sysTask).where(eq(schema.sysTask.id, record.id)).get()
|
||||
if (!current || current.errorCode === 'user_cancelled') return
|
||||
if (cancellingTasks.has(record.id)) return false
|
||||
await db.update(schema.sysTask).set({ resultUrl: imageUrl, stage: 'downloading', updatedAt: now() })
|
||||
.where(eq(schema.sysTask.id, record.id))
|
||||
const localPath = await downloadFile(imageUrl, 'images')
|
||||
// 列表页缩略图(前端按命名约定推导地址,失败不影响主流程)
|
||||
await generateImageThumb(localPath)
|
||||
@@ -684,7 +730,8 @@ async function writeBackImageAssets(record: SysTaskRecord, localPath: string) {
|
||||
|
||||
async function handleVideoComplete(record: SysTaskRecord, videoUrl: string, duration: number | null | undefined) {
|
||||
const [current] = await db.select().from(schema.sysTask).where(eq(schema.sysTask.id, record.id))
|
||||
if (!current || current.errorCode === 'user_cancelled' || cancellingTasks.has(record.id)) return
|
||||
if (!current || current.errorCode === 'user_cancelled') return
|
||||
if (cancellingTasks.has(record.id)) return false
|
||||
// 先落库地址与实际时长;后续下载失败仍可恢复,且不重新提交生成。
|
||||
const params: Record<string, unknown> = { ...parseTaskParams(current.params), resultDuration: duration }
|
||||
await db.update(schema.sysTask).set({ resultUrl: videoUrl, params: JSON.stringify(params),
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<p v-if="fresh && progress?.historyMaxSeconds">同显卡、同规格通常耗时约 {{ timeRange(progress.historyMinSeconds, progress.historyMaxSeconds) }}<small>参考 {{ progress.historySamples }} 次成功执行,不含排队与下载;属于估算。</small></p>
|
||||
<p v-else-if="!waiting && !finishing">整段视频预计耗时:暂无足够的同规格记录。</p>
|
||||
<p v-if="finishing">视频生成已结束,正在回传并保存到本地。</p>
|
||||
<p v-else-if="task?.stage === 'cancelling'">已请求停止此任务,正在等待显卡确认。</p>
|
||||
<p v-else-if="!fresh && !waiting" class="progress-note">{{ progress ? '进度更新暂时延迟,仍在跟踪原任务。' : '正在读取显卡进度…' }}</p>
|
||||
</section>
|
||||
</template>
|
||||
@@ -27,12 +28,12 @@ const progress = computed(() => {
|
||||
try { return JSON.parse(props.task?.params || '{}').progress || null } catch { return null }
|
||||
})
|
||||
const finishing = computed(() => ['transferring', 'downloading', 'saving'].includes(props.task?.stage))
|
||||
const fresh = computed(() => !finishing.value && progress.value && clock.value - progress.value.observedAt < 90000)
|
||||
const fresh = computed(() => !finishing.value && props.task?.stage !== 'cancelling' && progress.value && clock.value - progress.value.observedAt < 90000)
|
||||
const samplingFresh = computed(() => fresh.value && clock.value - progress.value.observedAt < 30000)
|
||||
const waiting = computed(() => ['waiting_predecessor', 'blocked_predecessor'].includes(props.task?.stage))
|
||||
const waiting = computed(() => ['waiting_predecessor', 'blocked_predecessor', 'cancelling'].includes(props.task?.stage))
|
||||
const phases: Record<string, string> = { queued:'等待显卡队列', running:'显卡正在处理', sampling:'视频采样中', decoding:'视频 / 音频解码中',
|
||||
encoding:'编码与保存中', loading:'模型 / 素材加载中', conditioning:'参考素材与提示词处理', postprocessing:'声音 / 字幕后处理',
|
||||
waiting_predecessor:'等待前镜头完成', blocked_predecessor:'等待处理前镜头', transferring:'视频回传中', downloading:'视频下载中', saving:'保存视频中', submitting:'提交任务中' }
|
||||
waiting_predecessor:'等待前镜头完成', blocked_predecessor:'等待处理前镜头', cancelling:'正在确认取消', transferring:'视频回传中', downloading:'视频下载中', saving:'保存视频中', submitting:'提交任务中' }
|
||||
const phaseLabel = computed(() => {
|
||||
const stage = props.task?.stage
|
||||
return phases[waiting.value || ['transferring','downloading','saving','submitting'].includes(stage) ? stage : fresh.value ? progress.value.phase : stage] || '生成任务处理中'
|
||||
|
||||
@@ -105,6 +105,7 @@ export const propAPI = {
|
||||
|
||||
// 统一生成任务(图片/视频):POST 带 type 字段,列表按 type 过滤
|
||||
export const taskAPI = {
|
||||
cancel: (id: number) => api.post(`/tasks/${id}/cancel`),
|
||||
generate: (d: any) => api.post('/tasks', d),
|
||||
recover: (id: number) => api.post(`/tasks/${id}/recover`),
|
||||
diagnostics: (id: number) => api.get(`/tasks/${id}/diagnostics`),
|
||||
|
||||
@@ -1043,6 +1043,7 @@
|
||||
<span class="video-task-metric is-pending">{{ t('episode.vid.metricPending', { n: genTaskActiveCount }) }}</span>
|
||||
<span class="video-task-metric is-done">{{ t('episode.vid.metricDone', { n: genTaskDoneCount }) }}</span>
|
||||
<span class="video-task-metric is-failed">{{ t('episode.vid.metricFailed', { n: genTaskFailedCount }) }}</span>
|
||||
<span v-if="genTaskCancelledCount" class="video-task-metric is-cancelled">{{ genTaskCancelledCount }} 已取消</span>
|
||||
</div>
|
||||
<div v-if="!genTaskRows.length" class="step-empty task-drawer-empty">
|
||||
<div class="empty-visual">
|
||||
@@ -1094,8 +1095,14 @@
|
||||
<span>#{{ row.id }}</span>
|
||||
</div>
|
||||
<TaskProgress v-if="row.kind === 'video'" :task="row.sourceTask" />
|
||||
<div v-if="row.errorMsg" class="video-task-error" :title="row.errorMsg">
|
||||
{{ mapError(row.errorMsg) }}
|
||||
<div v-if="row.canCancel" class="task-cancel-actions">
|
||||
<button class="btn btn-sm" :disabled="cancellingTaskIds.includes(row.id)" @click.stop="cancelTask(row)">
|
||||
{{ cancellingTaskIds.includes(row.id) ? '正在确认取消…' : row.stage === 'cancelling' ? '重试取消任务' : '取消任务' }}
|
||||
</button>
|
||||
<span v-if="row.stage === 'cancelling'">正在等待显卡确认</span>
|
||||
</div>
|
||||
<div v-if="row.errorMsg" :class="row.status === 'cancelled' ? 'task-cancelled-note' : 'video-task-error'" :title="row.errorMsg">
|
||||
{{ row.status === 'cancelled' ? row.errorMsg : mapError(row.errorMsg) }}
|
||||
<div v-if="row.kind !== 'merge'" class="flex gap-2">
|
||||
<button v-if="row.recoverable" class="btn btn-sm" @click.stop="recoverTask(row.id, row.storyboardId)">{{ t(row.kind === 'image' ? 'taskTracking.restoreImage' : 'episode.recovery.restore') }}</button>
|
||||
<button class="btn btn-sm" @click.stop="copyTaskDiagnostics(row.id)">{{ t('episode.recovery.diagnostics') }}</button>
|
||||
@@ -2016,6 +2023,7 @@ function videoModerationHint(msg) {
|
||||
function videoTaskState(sb) {
|
||||
if (isPendingVideo(sb?.id)) return 'pending'
|
||||
if (hasVid(sb)) return 'done'
|
||||
if (latestVideoTask(sb?.id)?.error_code === 'user_cancelled') return 'cancelled'
|
||||
if (videoFailMessage(sb?.id)) return 'failed'
|
||||
return 'ready'
|
||||
}
|
||||
@@ -2073,6 +2081,7 @@ async function recoverTask(taskId, storyboardId) {
|
||||
function videoTaskStatusLabel(sb) {
|
||||
const state = videoTaskState(sb)
|
||||
if (state === 'done') return t('episode.status.done')
|
||||
if (state === 'cancelled') return '已取消'
|
||||
if (state === 'pending') {
|
||||
const stage = latestVideoTask(sb.id)?.stage
|
||||
return stage === 'downloading' ? t('episode.recovery.downloading')
|
||||
@@ -2281,6 +2290,7 @@ function videoTaskRoutingLabel(task, compact = false) {
|
||||
}
|
||||
function canReplaceVideo(sb) {
|
||||
const task = latestVideoTask(sb?.id)
|
||||
if (task?.stage === 'cancelling') return false
|
||||
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)
|
||||
@@ -2407,9 +2417,27 @@ const genTaskDoneCount = computed(() =>
|
||||
genMerges.value.filter(m => m.status === 'completed').length
|
||||
)
|
||||
const genTaskFailedCount = computed(() =>
|
||||
genTasks.value.filter(t => t.status === 'failed').length +
|
||||
genTasks.value.filter(t => t.status === 'failed' && t.error_code !== 'user_cancelled').length +
|
||||
genMerges.value.filter(m => m.status === 'failed').length
|
||||
)
|
||||
const genTaskCancelledCount = computed(() => genTasks.value.filter(t => t.error_code === 'user_cancelled').length)
|
||||
const cancellingTaskIds = ref([])
|
||||
async function cancelTask(row) {
|
||||
if (!row.canCancel || cancellingTaskIds.value.includes(row.id)) return
|
||||
cancellingTaskIds.value.push(row.id)
|
||||
try {
|
||||
const result = await taskAPI.cancel(row.id)
|
||||
if (result.outcome === 'cancelled') toast.success('任务已取消,历史记录和已有素材保留')
|
||||
else if (result.outcome === 'completed') toast.info('任务已经生成,正在保存结果')
|
||||
else if (result.outcome === 'failed') toast.info('生成节点确认任务已结束,请查看任务记录')
|
||||
else toast.info('已请求取消,正在等待显卡确认;可稍后重试取消')
|
||||
} catch (error) { toastError(error) }
|
||||
finally {
|
||||
cancellingTaskIds.value = cancellingTaskIds.value.filter(id => id !== row.id)
|
||||
await refresh()
|
||||
await loadGenTasks()
|
||||
}
|
||||
}
|
||||
|
||||
function genTaskTargetLabel(task) {
|
||||
if (task.storyboard_id) {
|
||||
@@ -2441,7 +2469,8 @@ const genTaskRows = computed(() => {
|
||||
targetLabel: genTaskTargetLabel(t),
|
||||
provider: t.provider || '',
|
||||
model: t.model || '',
|
||||
status: t.status || 'processing',
|
||||
status: t.error_code === 'user_cancelled' ? 'cancelled' : t.status || 'processing',
|
||||
canCancel: t.can_cancel === true,
|
||||
errorMsg: t.error_msg || '',
|
||||
stage: t.stage || '',
|
||||
errorCode: t.error_code || '',
|
||||
@@ -2474,11 +2503,13 @@ function genTaskKindLabel(kind) {
|
||||
}
|
||||
|
||||
function trackedStageLabel(stage) {
|
||||
if (stage === 'cancelling') return '正在确认取消'
|
||||
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) {
|
||||
if (status === 'cancelled' || errorCode === 'user_cancelled') return '已取消'
|
||||
if (errorCode === 'upstream_missing') return t('taskTracking.missing')
|
||||
if (status === 'completed') return t('episode.status.done')
|
||||
if (status === 'failed') return t('episode.status.failed')
|
||||
@@ -2487,6 +2518,7 @@ function genTaskStatusLabel(status, stage, errorCode) {
|
||||
|
||||
// 映射到现有 video-task-status 的样式类:is-done / is-pending / is-failed
|
||||
function genTaskStateClass(status) {
|
||||
if (status === 'cancelled') return 'cancelled'
|
||||
if (status === 'completed') return 'done'
|
||||
if (status === 'failed') return 'failed'
|
||||
return 'pending'
|
||||
@@ -3538,6 +3570,11 @@ async function pollVideoGeneration(generationId, storyboardId) {
|
||||
return
|
||||
}
|
||||
if (res?.status === 'failed') {
|
||||
if ((res.errorCode || res.error_code) === 'user_cancelled') {
|
||||
pendingVideoIds.value = pendingVideoIds.value.filter(item => item !== storyboardId)
|
||||
delete failedVideoMessages.value[storyboardId]
|
||||
return
|
||||
}
|
||||
pendingVideoIds.value = pendingVideoIds.value.filter(item => item !== storyboardId)
|
||||
const errMsg = res?.error_msg || res?.errorMsg || t('episode.vid.genFailed')
|
||||
failedVideoMessages.value = {
|
||||
@@ -3605,6 +3642,10 @@ onMounted(() => setTimeout(() => autoTour('episode', EPISODE_TOUR, t), 900))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.task-cancel-actions { display:flex; align-items:center; gap:10px; margin:8px 0; font-size:12px; color:var(--text-3); }
|
||||
.task-cancelled-note { margin-top:6px; color:var(--text-3); font-size:13px; }
|
||||
.video-task-status.is-cancelled, .video-task-metric.is-cancelled { color:var(--text-3); background:var(--bg-2); border-color:var(--border-color); }
|
||||
.gen-task-row.is-cancelled { background:var(--bg-2); }
|
||||
.voice-reference-hint {
|
||||
margin: 8px 0 0;
|
||||
font-size: 11px;
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// Inspect the installed app using GET requests only; never cancel live jobs.
|
||||
const {chromium}=require('playwright-core'),fs=require('node:fs'),assert=require('node:assert/strict');
|
||||
(async()=>{
|
||||
const root='D:/web/dj',api=JSON.parse(fs.readFileSync(root+'/huobao-data/last-launch.json')).api;
|
||||
const tasks=(await (await fetch(api+'/episodes/7/generation-tasks')).json()).data.tasks;
|
||||
const browser=await chromium.launch({channel:'chrome',headless:true});
|
||||
try {
|
||||
const page=await browser.newPage({viewport:{width:1600,height:1200}}),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(api.replace('/api/v1','')+'/drama/6/episode/1',{waitUntil:'networkidle'});
|
||||
await page.getByRole('button',{name:/^任务/}).click();
|
||||
const drawer=page.locator('.task-drawer-overlay');await drawer.waitFor();
|
||||
const row=id=>drawer.locator('.gen-task-row').filter({has:page.locator('.video-task-meta-line').filter({hasText:'#'+id})});
|
||||
const active=tasks.filter(t=>t.can_cancel);
|
||||
for(const task of active) {
|
||||
assert.equal(await row(task.id).getByRole('button',{name:/^(重试)?取消任务$/,exact:true}).count(),1);
|
||||
}
|
||||
const cancelled=tasks.filter(t=>t.error_code==='user_cancelled');
|
||||
for(const task of cancelled)assert.equal(await row(task.id).locator('.video-task-status.is-cancelled').count(),1);
|
||||
if(active.length)await row(active[active.length-1].id).scrollIntoViewIfNeeded();
|
||||
await page.screenshot({path:root+'/integration-work/cancel-installed.png'});
|
||||
assert.deepEqual(errors,[]);
|
||||
console.log('PASS: installed cancel buttons for tasks',active.map(t=>t.id),'; cancelled history',cancelled.length,'; GET-only verification');
|
||||
}finally{await browser.close()}
|
||||
})().catch(e=>{console.error(e.stack);process.exit(1)});
|
||||
@@ -0,0 +1,49 @@
|
||||
const {chromium}=require('playwright-core'),assert=require('node:assert/strict');
|
||||
(async()=>{
|
||||
const browser=await chromium.launch({channel:'chrome',headless:true});
|
||||
try {
|
||||
const page=await browser.newPage({viewport:{width:1500,height:1100}}),errors=[],calls=[],states={};
|
||||
page.on('pageerror',e=>errors.push(e.message));
|
||||
await page.addInitScript(()=>localStorage.setItem('huobao:tours',JSON.stringify(['episode'])));
|
||||
await page.route('**/api/**',async route=>{
|
||||
const req=route.request(),url=req.url(),match=url.match(/\/tasks\/(\d+)\/cancel$/);
|
||||
if(req.method()==='POST'&&match){
|
||||
const id=+match[1];assert.ok([237,239].includes(id));calls.push(id);
|
||||
states[id]=id===239&&!states[id]?'pending':'cancelled';
|
||||
return route.fulfill({status:200,json:{code:200,data:{outcome:states[id]}}});
|
||||
}
|
||||
if(!['GET','HEAD'].includes(req.method()))throw Error('Unexpected mutation: '+req.method()+' '+url);
|
||||
if(url.includes('/generation-tasks')){
|
||||
const response=await route.fetch(),body=await response.json();
|
||||
for(const task of body.data.tasks){
|
||||
if(![237,239].includes(task.id))continue;
|
||||
task.status=states[task.id]==='cancelled'?'failed':'processing';
|
||||
task.error_code=states[task.id]==='cancelled'?'user_cancelled':null;
|
||||
task.error_msg=states[task.id]==='cancelled'?'任务已取消,历史记录和已有素材保留':null;
|
||||
task.stage=states[task.id]==='pending'?'cancelling':task.id===239?'queued':'running';
|
||||
task.can_cancel=task.status==='processing';
|
||||
}
|
||||
return route.fulfill({response,json:body});
|
||||
}
|
||||
return route.continue();
|
||||
});
|
||||
await page.goto('http://127.0.0.1:18938/drama/6/episode/1',{waitUntil:'networkidle'});
|
||||
await page.getByRole('button',{name:/^任务/}).click();
|
||||
const drawer=page.locator('.task-drawer-overlay');await drawer.waitFor();
|
||||
const row=id=>drawer.locator('.gen-task-row').filter({has:page.locator('.video-task-meta-line').filter({hasText:'#'+id})});
|
||||
assert.equal(await row(237).getByRole('button',{name:'取消任务',exact:true}).count(),1);
|
||||
await row(239).getByRole('button',{name:'取消任务',exact:true}).click();
|
||||
await row(239).getByRole('button',{name:'重试取消任务',exact:true}).waitFor();
|
||||
assert.match(await row(239).innerText(),/正在等待显卡确认/);
|
||||
assert.equal(await row(237).getByRole('button',{name:'取消任务',exact:true}).count(),1);
|
||||
await row(239).getByRole('button',{name:'重试取消任务',exact:true}).click();
|
||||
await row(239).locator('.video-task-status.is-cancelled').waitFor();
|
||||
assert.match(await row(239).innerText(),/已取消/);
|
||||
assert.equal(await row(239).getByRole('button',{name:/取消任务/}).count(),0);
|
||||
await row(237).getByRole('button',{name:'取消任务',exact:true}).click();
|
||||
await row(237).locator('.video-task-status.is-cancelled').waitFor();
|
||||
assert.deepEqual(calls,[239,239,237]);assert.deepEqual(errors,[]);
|
||||
await page.screenshot({path:'D:/web/dj/integration-work/cancel-ui-fixture.png'});
|
||||
console.log('PASS: per-image/video cancel buttons; pending confirmation/retry; cancelled styling/counts; no real cancellations');
|
||||
}finally{await browser.close()}
|
||||
})().catch(e=>{console.error(e.stack);process.exit(1)});
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Patch only the existing private bridges; no real jobs are cancelled."""
|
||||
from pathlib import Path
|
||||
import datetime,subprocess,json
|
||||
base=Path('/home/ps/huobao-progress-update')
|
||||
backup=base/('cancel-backup-'+datetime.datetime.now().strftime('%Y%m%d-%H%M%S'))
|
||||
backup.mkdir(mode=0o700)
|
||||
endpoint=''' @app.post("/v1/images/task/{jid}/cancel", dependencies=[Depends(auth)])
|
||||
async def cancel_image(jid: str):
|
||||
with closing(sqlite3.connect(db_path)) as db:
|
||||
found = db.execute("SELECT 1 FROM image_jobs WHERE id=?", (jid,)).fetchone()
|
||||
if not found:
|
||||
raise HTTPException(404, "Unknown image task")
|
||||
result = await upstream("POST", "/v1/jobs/" + quote(jid, safe="") + "/cancel", {})
|
||||
return {"task": {"id": jid, "status": result.get("status", "running"),
|
||||
"cancelRequested": result.get("cancelRequested", False)}}
|
||||
|
||||
'''
|
||||
for name,location in [('huobao-model-bridge','/home/ps/huobao-model-bridge/bridge.py'),('huobao-vdn-bridge','/home/ps/huobao-vdn/bridge/bridge.py')]:
|
||||
p=Path(location);original=p.read_text();s=original
|
||||
if 'async def cancel_image(' not in s:
|
||||
target=' @app.get("/v1/images/task/{jid}", dependencies=[Depends(auth)])'
|
||||
assert s.count(target)==1;s=s.replace(target,endpoint+target)
|
||||
for old,new in [
|
||||
('return {"status": "failed", "error": {"message": str(result.get("error") or "Image generation failed")[:600]}}', 'return {"task_id": jid, "status": "failed", "cancelled": state == "cancelled", "error": {"message": str(result.get("error") or "Image generation failed")[:600]}}'),
|
||||
('return {"status": "processing", "provider_id": result.get("providerId"), "worker_state": result.get("worker_state"), "queue_position": result.get("queue_position")}', 'return {"task_id": jid, "status": "processing", "provider_id": result.get("providerId"), "worker_state": result.get("worker_state"), "queue_position": result.get("queue_position")}'),
|
||||
('return {"status": "failed", "error": {"message": "Task completed without an image output"}}', 'return {"task_id": jid, "status": "failed", "error": {"message": "Task completed without an image output"}}'),
|
||||
('return {"status": "completed", "image_url": url, "data": [{"url": url}], "provider_id": result.get("providerId")}', 'return {"task_id": jid, "status": "completed", "image_url": url, "data": [{"url": url}], "provider_id": result.get("providerId")}')]:
|
||||
if new not in s:assert s.count(old)==1;s=s.replace(old,new)
|
||||
compile(s,location,'exec');(backup/(name+'.py')).write_text(original);p.write_text(s)
|
||||
subprocess.run(['docker','restart','--time','15',name],check=True,capture_output=True)
|
||||
assert json.loads(subprocess.check_output(['docker','inspect',name]))[0]['State']['Running']
|
||||
print(name,'updated')
|
||||
print('backup',backup)
|
||||
@@ -21,7 +21,7 @@ assert count == 1
|
||||
source, count = re.subn(r'&& !!taskRouting\(\{ provider: config\d*\.provider, taskId \}\)', '&& !!taskId', source)
|
||||
assert count == 1
|
||||
(data / 'backend.mjs').write_text(source, 'utf-8')
|
||||
events = []; reject = False; process = None
|
||||
events = []; reject = False; process = None; cancel_modes = {}; job_states = {}
|
||||
|
||||
class Mock(BaseHTTPRequestHandler):
|
||||
def log_message(self, *args): pass
|
||||
@@ -37,10 +37,19 @@ class Mock(BaseHTTPRequestHandler):
|
||||
self.reply(400, {'error': {'message': 'Local video requires inline image data'}})
|
||||
else: self.reply(200, {'valid': True})
|
||||
elif self.path.endswith('/cancel'):
|
||||
self.reply(200, {'task': {'id': self.path.split('/')[-2], 'status': 'cancelled'}})
|
||||
jid=self.path.split('/')[-2];mode=cancel_modes.get(jid,'cancelled')
|
||||
if mode=='wrong-id':self.reply(200,{'task':{'id':'different-job','status':'cancelled'}})
|
||||
elif mode=='transport':self.reply(503,{'error':'transient'})
|
||||
else:
|
||||
job_states[jid]=mode
|
||||
self.reply(200, {'task': {'id':jid, 'status':mode}})
|
||||
else:
|
||||
self.reply(200, {'task_id': 'new-' + str(len(events)), 'routing': {'gpu': body['gpu'], 'port': 8191, 'providerId': 'gpu3', 'legacy': False}})
|
||||
def do_GET(self):
|
||||
if self.path.startswith('/v1/images/task/'):
|
||||
jid=self.path.split('/')[-1];state=job_states.get(jid,'running')
|
||||
self.reply(200,{'task_id':jid,'status':'failed' if state=='cancelled' else 'processing',
|
||||
'cancelled':state=='cancelled','worker_state':'running','error':{'message':'cancelled'}});return
|
||||
self.reply(200, {'task': {'id': self.path.split('/')[-1], 'status': 'running', 'worker_state': 'running',
|
||||
'progress': {'phase':'sampling','step':4,'totalSteps':20,'observedAt':round(time.time()*1000),'samplingMinSeconds':120,'samplingMaxSeconds':180}}})
|
||||
|
||||
@@ -119,8 +128,49 @@ try:
|
||||
time.sleep(.1)
|
||||
assert progress['step']==4 and progress['totalSteps']==20
|
||||
assert progress['samplingMaxSeconds']==180
|
||||
# Standalone cancellation never creates another generation or touches neighbors.
|
||||
before=len(events);assert api('POST','/tasks/2/cancel',{})['outcome']=='cancelled'
|
||||
assert events[-1][0]=='/v2/video_generation/old-2/cancel' and len(events)==before+1
|
||||
assert sql('SELECT status FROM sys_task WHERE id=3')[0]['status']=='processing'
|
||||
before=len(events);assert api('POST','/tasks/2/cancel',{})['outcome']=='cancelled';assert len(events)==before
|
||||
sql('INSERT INTO ai_service_configs(id,service_type,provider,name,base_url,api_key,model,is_active,created_at,updated_at) VALUES(2,?,?,?,?,?,?,1,?,?)',
|
||||
('image','openai','fixture-image','http://127.0.0.1:18935/v1','test-key',json.dumps(['qwen_image_2_1']),ts,ts))
|
||||
for i in [20,21,22,23]:
|
||||
sql('INSERT INTO sys_task(id,type,provider,model,status,stage,task_id,config_id,config_base_url,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
|
||||
(i,'image','openai','qwen_image_2_1','processing','submitting' if i==23 else 'queued',None if i==23 else 'image-'+str(i),2,'http://127.0.0.1:18935/v1',ts,ts))
|
||||
sql('INSERT INTO sys_task(id,type,status,stage,local_path,created_at,updated_at) VALUES(19,?,?,?,?,?,?)',('image','completed','completed','static/images/reference.png',ts,ts))
|
||||
assert api('GET','/tasks/20')['canCancel'] is True
|
||||
before=len(events);assert api('POST','/tasks/20/cancel',{})['outcome']=='cancelled'
|
||||
assert events[-1][0]=='/v1/images/task/image-20/cancel' and len(events)==before+1
|
||||
assert sql('SELECT local_path FROM sys_task WHERE id=19')[0]['local_path']=='static/images/reference.png'
|
||||
assert (data/'static/images/reference.png').read_bytes()==png
|
||||
# Wrong receipt / transport failures are unconfirmed, never falsely cancelled.
|
||||
cancel_modes['image-21']='wrong-id'
|
||||
assert api('POST','/tasks/21/cancel',{})['outcome']=='pending'
|
||||
assert sql('SELECT status,stage FROM sys_task WHERE id=21')[0]=={'status':'processing','stage':'cancelling'}
|
||||
cancel_modes['image-21']='transport'
|
||||
assert api('POST','/tasks/21/cancel',{})['outcome']=='pending'
|
||||
# A completion racing with cancellation is preserved for normal download.
|
||||
cancel_modes['image-22']='succeeded'
|
||||
assert api('POST','/tasks/22/cancel',{})['outcome']=='completed'
|
||||
assert sql('SELECT error_code,status FROM sys_task WHERE id=22')[0]=={'error_code':None,'status':'processing'}
|
||||
before=len(events);api('POST','/tasks/23/cancel',{},expected=400);api('POST','/tasks/19/cancel',{},expected=400)
|
||||
assert len(events)==before and api('GET','/tasks/23')['canCancel'] is False
|
||||
sql('INSERT INTO sys_task(id,type,status,stage,created_at,updated_at) VALUES(24,?,?,?,?,?)',('video','processing','waiting_predecessor',ts,ts))
|
||||
assert api('POST','/tasks/24/cancel',{})['outcome']=='cancelled' and len(events)==before
|
||||
# The unconfirmed request survives restart; polling confirms the same ID,
|
||||
# without another cancel or generation request.
|
||||
job_states['image-21']='cancelled'
|
||||
process.terminate();process.wait(timeout=10)
|
||||
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(180):
|
||||
row=sql('SELECT status,error_code FROM sys_task WHERE id=21')[0]
|
||||
if row['error_code']=='user_cancelled':break
|
||||
time.sleep(.1)
|
||||
assert row=={'status':'failed','error_code':'user_cancelled'} and len(events)==before
|
||||
print(json.dumps({'passed':True,'inline_reference_preflight':True,'first_frame':True,'cancel_only_selected_shot':True,
|
||||
'preserve_old_on_missing_image':True,'preserve_old_on_validation_failure':True,'fixture':str(data)}))
|
||||
'preserve_old_on_missing_image':True,'preserve_old_on_validation_failure':True,'image_cancel':True,'cancel_idempotent':True,
|
||||
'completion_race_preserved':True,'bad_receipt_not_cancelled':True,'cancel_survives_restart':True,'fixture':str(data)}))
|
||||
finally:
|
||||
if process and process.poll() is None: process.terminate(); process.wait(timeout=10)
|
||||
server.shutdown(); log.close()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Serve built frontend with a read-only proxy to verify progress; no DB writes."""
|
||||
import http.server,json,pathlib,subprocess,threading,urllib.request,urllib.error
|
||||
import http.server,json,pathlib,subprocess,threading,urllib.request,urllib.error,sys
|
||||
root=pathlib.Path(__file__).resolve().parent.parent
|
||||
public=root/'huobao-source/frontend/.output/public'
|
||||
upstream=json.loads((root/'huobao-data/last-launch.json').read_text())['api'].removesuffix('/api/v1')
|
||||
@@ -18,5 +18,7 @@ class ReadOnly(http.server.SimpleHTTPRequestHandler):
|
||||
super().do_GET()
|
||||
server=http.server.ThreadingHTTPServer(('127.0.0.1',18938),ReadOnly)
|
||||
threading.Thread(target=server.serve_forever,daemon=True).start()
|
||||
try:subprocess.run(['node',str(root/'integration-work/browser-test/verify_progress_ui.cjs')],cwd=root,check=True)
|
||||
script = sys.argv[1] if len(sys.argv)>1 else 'verify_progress_ui.cjs'
|
||||
if script not in {'verify_progress_ui.cjs','verify_cancel_ui.cjs'}:raise ValueError('Unknown UI test')
|
||||
try:subprocess.run(['node',str(root/'integration-work/browser-test'/script)],cwd=root,check=True)
|
||||
finally:server.shutdown()
|
||||
|
||||
+14
-4
@@ -458,6 +458,16 @@ def create_app(config):
|
||||
async def edit_image(request: Request):
|
||||
return await submit_image(await parse_image_edit(request))
|
||||
|
||||
@app.post("/v1/images/task/{jid}/cancel", dependencies=[Depends(auth)])
|
||||
async def cancel_image(jid: str):
|
||||
with closing(sqlite3.connect(db_path)) as db:
|
||||
found = db.execute("SELECT 1 FROM image_jobs WHERE id=?", (jid,)).fetchone()
|
||||
if not found:
|
||||
raise HTTPException(404, "Unknown image task")
|
||||
result = await upstream("POST", "/v1/jobs/" + quote(jid, safe="") + "/cancel", {})
|
||||
return {"task": {"id": jid, "status": result.get("status", "running"),
|
||||
"cancelRequested": result.get("cancelRequested", False)}}
|
||||
|
||||
@app.get("/v1/images/task/{jid}", dependencies=[Depends(auth)])
|
||||
async def poll_image(jid: str):
|
||||
with closing(sqlite3.connect(db_path)) as db:
|
||||
@@ -467,17 +477,17 @@ def create_app(config):
|
||||
result = await upstream("GET", "/v1/huobao/jobs/" + quote(jid, safe=""))
|
||||
state = result.get("status", "running")
|
||||
if state in {"failed", "cancelled"}:
|
||||
return {"status": "failed", "error": {"message": str(result.get("error") or "Image generation failed")[:600]}}
|
||||
return {"task_id": jid, "status": "failed", "cancelled": state == "cancelled", "error": {"message": str(result.get("error") or "Image generation failed")[:600]}}
|
||||
if state != "succeeded":
|
||||
return {"status": "processing", "provider_id": result.get("providerId"), "worker_state": result.get("worker_state"), "queue_position": result.get("queue_position")}
|
||||
return {"task_id": jid, "status": "processing", "provider_id": result.get("providerId"), "worker_state": result.get("worker_state"), "queue_position": result.get("queue_position")}
|
||||
output = next((o for o in result.get("outputs", []) if o.get("kind") == "image"), None)
|
||||
if output is None:
|
||||
return {"status": "failed", "error": {"message": "Task completed without an image output"}}
|
||||
return {"task_id": jid, "status": "failed", "error": {"message": "Task completed without an image output"}}
|
||||
index, expiry = int(output["index"]), int(time.time()) + 86400
|
||||
extension = {"image/png": "png", "image/jpeg": "jpg", "image/webp": "webp"}.get(output.get("mimeType"), "png")
|
||||
# Huobao determines the saved extension from the URL, not Content-Type.
|
||||
url = f"{config['public_url']}/media/{quote(jid, safe='')}/{index}/image.{extension}?expires={expiry}&sig={signature(jid, index, expiry)}"
|
||||
return {"status": "completed", "image_url": url, "data": [{"url": url}], "provider_id": result.get("providerId")}
|
||||
return {"task_id": jid, "status": "completed", "image_url": url, "data": [{"url": url}], "provider_id": result.get("providerId")}
|
||||
|
||||
@app.post("/v2/video_generation/validate", dependencies=[Depends(auth)])
|
||||
async def validate_video(body: dict):
|
||||
|
||||
@@ -31,7 +31,7 @@ for directory in ('prompts', 'skills'):
|
||||
shutil.copytree(root/'huobao-source/backend/workspace'/directory,
|
||||
stage/'resources/workspace-template'/directory,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','cross-episode-continuity','tail-frame-dependency-queue','storyboard-timeline-validation','gpu-replacement-inline-references','comfy-live-progress-and-estimates'],
|
||||
'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','storyboard-timeline-validation','gpu-replacement-inline-references','comfy-live-progress-and-estimates','per-task-image-video-cancel'],
|
||||
'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'])
|
||||
|
||||
@@ -12,6 +12,28 @@ from bridge import VIDEO_MODELS, create_app, h3_request, video_workflows
|
||||
|
||||
|
||||
class BridgeTests(unittest.TestCase):
|
||||
def test_cancel_image_is_authenticated_owned_and_exact(self):
|
||||
self.assertEqual(self.client.post('/v1/images/task/unknown/cancel').status_code,401)
|
||||
self.assertEqual(self.client.post('/v1/images/task/unknown/cancel',headers=self.headers).status_code,404)
|
||||
video=self.client.post('/v2/video_generation',json=self.payload(),headers=self.headers).json()['task_id']
|
||||
self.assertEqual(self.client.post('/v1/images/task/'+video+'/cancel',headers=self.headers).status_code,404)
|
||||
jid=self.client.post('/v1/images/generations',json=self.image_payload(),headers=self.headers).json()['task_id']
|
||||
self.image_status='cancelled'
|
||||
result=self.client.post('/v1/images/task/'+jid+'/cancel',headers=self.headers)
|
||||
self.assertEqual(result.status_code,200)
|
||||
self.assertEqual(result.json()['task'],{'id':jid,'status':'cancelled','cancelRequested':False})
|
||||
calls=[r for r in self.calls if r.url.path.endswith('/cancel')]
|
||||
self.assertEqual([r.url.path for r in calls],['/v1/jobs/'+jid+'/cancel'])
|
||||
polled=self.client.get('/v1/images/task/'+jid,headers=self.headers).json()
|
||||
self.assertTrue(polled['cancelled']);self.assertEqual(polled['task_id'],jid)
|
||||
|
||||
def test_completed_image_is_not_reported_cancelled(self):
|
||||
jid=self.client.post('/v1/images/generations',json=self.image_payload(),headers=self.headers).json()['task_id']
|
||||
self.image_status='succeeded'
|
||||
result=self.client.post('/v1/images/task/'+jid+'/cancel',headers=self.headers).json()
|
||||
self.assertEqual(result['task']['status'],'succeeded')
|
||||
polled=self.client.get('/v1/images/task/'+jid,headers=self.headers).json()
|
||||
self.assertEqual(polled['status'],'completed');self.assertEqual(polled['task_id'],jid)
|
||||
def test_local_worker_state_is_forwarded_without_claiming_failure(self):
|
||||
video=self.client.post('/v2/video_generation',json=self.payload(),headers=self.headers).json()['task_id']
|
||||
self.client.post('/v1/images/generations',json=self.image_payload(),headers=self.headers)
|
||||
@@ -127,7 +149,7 @@ class BridgeTests(unittest.TestCase):
|
||||
if data["workflow_key"] == "qwen_image_2_1":
|
||||
return httpx.Response(200, json={"id": "image-job"+suffix, "status": "submitted", "providerId": worker})
|
||||
return httpx.Response(200, json={"id": "test-job"+suffix, "status": "submitted", "providerId": worker})
|
||||
if request.url.path == "/v1/huobao/jobs/image-job":
|
||||
if request.url.path in ("/v1/huobao/jobs/image-job", "/v1/jobs/image-job/cancel"):
|
||||
return httpx.Response(200, json={"status": self.image_status, "error": "image sampler failed", "outputs": self.image_outputs, "providerId": "gpu3", "worker_state":getattr(self,'worker_state','running')})
|
||||
if request.url.path == "/v1/jobs/image-job/outputs/0":
|
||||
return httpx.Response(200, content=b"test-image-bytes", headers={"content-type": "image/png"})
|
||||
|
||||
Reference in New Issue
Block a user