Add per-task image and video cancellation with confirmed worker receipts

This commit is contained in:
Codex
2026-11-12 11:43:20 +08:00
parent e02f031378
commit 0af38fa742
18 changed files with 353 additions and 55 deletions
+7
View File
@@ -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。
## 取消任务
打开右上角「任务」,在对应图片或视频记录上点击「取消任务」。只停止这一条任务,不影响其他镜头、其他显卡任务或已有素材。等待前镜头的任务也可以直接取消。
已提交的任务需要收到对应节点的取消确认,才会显示「已取消」;未收到回执时显示「正在等待显卡确认」,可重试取消,重启后仍会跟踪原任务。提交中尚无任务编号、已经完成或正在保存结果的任务不提供取消按钮。已取消任务单独统计,不计入失败数量。
## 目录
| 目录或文件 | 用途 |
+2 -1
View File
@@ -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),
})
})
+8 -3
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, 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)});
+33
View File
@@ -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()
+4 -2
View File
@@ -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
View File
@@ -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):
+1 -1
View File
@@ -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'])
+23 -1
View File
@@ -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"})