Fix GPU replacement preflight with local reference images

This commit is contained in:
Codex
2026-11-12 11:06:23 +08:00
parent c151a056de
commit 3526ed9b31
4 changed files with 139 additions and 18 deletions
@@ -145,13 +145,19 @@ export async function generateVideo(params: GenerateVideoParams): Promise<number
previewParams.referenceFileUrl = undefined; previewParams.referenceLinkUrl = undefined
}
// Validate the new request before touching the previous job.
const validatedRequest = getVideoAdapter(config.provider).buildGenerateRequest(config, { ...previewParams, id: 0,
getVideoAdapter(config.provider).buildGenerateRequest(config, { ...previewParams, id: 0,
referenceImageUrls: JSON.stringify(previewParams.referenceImageUrls || []),
referenceVideoUrls: JSON.stringify(previewParams.referenceVideoUrls || []),
referenceAudioUrls: JSON.stringify(previewParams.referenceAudioUrls || []) })
if (params.replaceTaskId && ['minimax', 'localvideo'].includes(config.provider)) {
const target = new URL(config.baseUrl)
if (target.hostname === '192.168.110.111' && ['18792', '18794'].includes(target.port)) {
// Preflight must see the same inline images as submission. A static/
// path is local to this computer and cannot be read by the GPU server.
const references = await prepareVideoReferences(previewParams, config.provider)
const validatedRequest = getVideoAdapter(config.provider).buildGenerateRequest(config, {
...previewParams, id: 0, ...references,
})
const response = await fetch(`${config.baseUrl.replace(/\/$/, '')}/v2/video_generation/validate`, {
method: 'POST', headers: { Authorization: `Bearer ${config.apiKey}`, 'Content-Type': 'application/json' },
body: JSON.stringify(validatedRequest.body), signal: AbortSignal.timeout(35_000) })
@@ -369,26 +375,13 @@ async function processTask(id: number, config: AIConfig) {
}))
} else {
const adapter = getVideoAdapter(config.provider)
const resolvedImageUrl = await normalizeVideoReferenceUrl(params.imageUrl)
const resolvedFirstFrameUrl = await normalizeVideoReferenceUrl(params.firstFrameUrl)
const resolvedLastFrameUrl = await normalizeVideoReferenceUrl(params.lastFrameUrl)
const resolvedReferenceImageUrls = await normalizeVideoReferenceUrls(params.referenceImageUrls)
// 视频使用公网地址;短音色按厂商能力使用 data URL 或公网地址。
const resolvedReferenceVideoUrls = resolvePublicMediaUrls(params.referenceVideoUrls, 'video')
const resolvedReferenceAudioUrls = await resolveReferenceAudioUrls(params.referenceAudioUrls, config.provider)
const resolvedReferenceFileUrl = resolvePublicMediaUrl(params.referenceFileUrl, 'file')
const references = await prepareVideoReferences(params, config.provider)
;({ url, method, headers, body } = adapter.buildGenerateRequest(config, {
id: record.id,
model: record.model,
prompt: record.prompt,
referenceMode: params.referenceMode,
imageUrl: resolvedImageUrl,
firstFrameUrl: resolvedFirstFrameUrl,
lastFrameUrl: resolvedLastFrameUrl,
referenceImageUrls: resolvedReferenceImageUrls.length ? JSON.stringify(resolvedReferenceImageUrls) : null,
referenceVideoUrls: resolvedReferenceVideoUrls.length ? JSON.stringify(resolvedReferenceVideoUrls) : null,
referenceAudioUrls: resolvedReferenceAudioUrls.length ? JSON.stringify(resolvedReferenceAudioUrls) : null,
referenceFileUrl: resolvedReferenceFileUrl,
...references,
referenceLinkUrl: params.referenceLinkUrl,
generateAudio: params.generateAudio,
duration: params.duration,
@@ -872,6 +865,21 @@ async function normalizeReferenceImages(refs: string[] | null | undefined): Prom
return normalized
}
async function prepareVideoReferences(params: Pick<GenerateVideoParams, 'imageUrl' | 'firstFrameUrl' | 'lastFrameUrl' |
'referenceImageUrls' | 'referenceVideoUrls' | 'referenceAudioUrls' | 'referenceFileUrl'>, provider: string) {
const imageUrl = await normalizeVideoReferenceUrl(params.imageUrl)
const firstFrameUrl = await normalizeVideoReferenceUrl(params.firstFrameUrl)
const lastFrameUrl = await normalizeVideoReferenceUrl(params.lastFrameUrl)
const images = await normalizeVideoReferenceUrls(params.referenceImageUrls)
const videos = resolvePublicMediaUrls(params.referenceVideoUrls, 'video')
const audios = await resolveReferenceAudioUrls(params.referenceAudioUrls, provider)
return { imageUrl, firstFrameUrl, lastFrameUrl,
referenceImageUrls: images.length ? JSON.stringify(images) : null,
referenceVideoUrls: videos.length ? JSON.stringify(videos) : null,
referenceAudioUrls: audios.length ? JSON.stringify(audios) : null,
referenceFileUrl: resolvePublicMediaUrl(params.referenceFileUrl, 'file') }
}
async function normalizeVideoReferenceUrl(value: string | null | undefined): Promise<string | null> {
const raw = String(value || '').trim()
if (!raw) return null
@@ -883,7 +891,7 @@ async function normalizeVideoReferenceUrl(value: string | null | undefined): Pro
return readImageAsDataUrl(localPath)
} catch (err) {
logTaskWarn('VideoTask', 'reference-read-failed', { path: localPath, error: (err as Error).message })
return null
throw new Error('参考图读取失败,请重新上传后再生成')
}
}
return raw
@@ -52,6 +52,7 @@ export function mapError(err: unknown, opts?: { fallback?: string }): string {
// Known local worker condition: preserve the actionable GPU/port hint without
// exposing arbitrary upstream JSON, URLs or credentials in the task list.
if (/GPU1\s*\/\s*8189 暂不可用:旧 GPU1 任务或显存尚未释放/.test(msg)) return t('errors.gpu1Busy')
if (msg.startsWith('新显卡未就绪,原任务保持运行:')) return '新显卡检查未通过,原任务仍在运行,请检查显卡和模型设置'
if (SERVER5XX_RE.test(msg)) return t('errors.unavailable')
if (TIMEOUT_RE.test(msg)) return t('errors.timeout')
@@ -0,0 +1,112 @@
"""Exercise GPU replacement preflight/cancellation with local images, no real GPU jobs."""
import base64, json, os, pathlib, re, sqlite3, subprocess, threading, time, urllib.error, urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
root = pathlib.Path(__file__).resolve().parent.parent
data = root / 'integration-work' / ('replacement-test-' + time.strftime('%Y%m%d-%H%M%S'))
data.mkdir(); (data / 'static/images').mkdir(parents=True)
png = base64.b64decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aN1kAAAAASUVORK5CYII=')
(data / 'static/images/reference.png').write_bytes(png)
stage = root / 'integration-work/stage-4.0.11/app'
source = (root / 'huobao-source/desktop/build/backend.mjs').read_text('utf-8')
source = source.replace('const require = __hbCreateRequire(import.meta.url)', 'const require = __hbCreateRequire(' + json.dumps(str(stage / 'resources/app.asar/build/backend.mjs')) + ')', 1)
source = re.sub(r'^import (\w+) from "(better-sqlite3|sharp)";', r'const \1 = require("\2");', source, flags=re.M)
needle = 'target.hostname === "192.168.110.111" && ["18792", "18794"].includes(target.port)'
assert source.count(needle) == 1
source = source.replace(needle, 'target.hostname === "127.0.0.1" && target.port === "18935"')
(data / 'backend.mjs').write_text(source, 'utf-8')
events = []; reject = False; process = None
class Mock(BaseHTTPRequestHandler):
def log_message(self, *args): pass
def reply(self, status, body):
raw = json.dumps(body).encode(); self.send_response(status)
self.send_header('Content-Type', 'application/json'); self.end_headers(); self.wfile.write(raw)
def do_POST(self):
body = json.loads(self.rfile.read(int(self.headers['Content-Length'])))
events.append((self.path, body))
if self.path.endswith('/validate'):
images = [x['image_url']['url'] for x in body['content'] if x['type'] == 'image_url']
if reject or not images or any(not x.startswith('data:image/') for x in images):
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'}})
else:
self.reply(200, {'task_id': 'new-' + str(len(events)), 'routing': {'gpu': body['gpu'], 'port': 8191, 'providerId': 'gpu3', 'legacy': False}})
def do_GET(self):
self.reply(200, {'task': {'id': self.path.split('/')[-1], 'status': 'running', 'worker_state': 'running'}})
server = ThreadingHTTPServer(('127.0.0.1', 18935), Mock)
threading.Thread(target=server.serve_forever, daemon=True).start()
env = {**os.environ, 'ELECTRON_RUN_AS_NODE': '1', 'PORT': '18934', 'HUOBAO_DATA_DIR': str(data),
'SQLITE_PATH': str(data / 'huobao.sqlite3'), 'FRONTEND_DIST': str(root / 'huobao-source/frontend/.output/public'),
'WORKSPACE_PATH': str(stage / 'resources/workspace-template')}
log = (data / 'backend.log').open('w', encoding='utf-8')
def api(method, path, body=None, expected=200):
request = urllib.request.Request('http://127.0.0.1:18934/api/v1' + path, method=method,
data=None if body is None else json.dumps(body).encode(), headers={'Content-Type': 'application/json'})
try:
with urllib.request.urlopen(request, timeout=40) as response: status = response.status; result = json.load(response)
except urllib.error.HTTPError as error: status = error.code; result = json.load(error)
assert status == expected, (path, status, result)
return result.get('data', result)
def sql(statement, args=()):
with sqlite3.connect(data / 'huobao.sqlite3') as db:
db.row_factory = sqlite3.Row
return [dict(row) for row in db.execute(statement, args)]
def replace(shot, old, **extra):
return api('POST', '/tasks', {'type': 'video', 'storyboard_id': shot, 'config_id': 1,
'model': 'MiniMax-H3', 'gpu': 'gpu3', 'replace_task_id': old, 'prompt': 'reference fixture',
'reference_image_urls': ['static/images/reference.png'], **extra.pop('body', {})}, **extra)
try:
process = subprocess.Popen([str(stage / 'HuobaoDrama.exe'), str(data / 'backend.mjs')], cwd=data,
env=env, stdout=log, stderr=log, creationflags=subprocess.CREATE_NO_WINDOW)
for _ in range(150):
assert process.poll() is None, 'Backend exited'
try: api('GET', '/health'); break
except OSError: time.sleep(.1)
ts = '2026-11-12T00:00:00Z'
sql('INSERT INTO dramas(id,title,created_at,updated_at) VALUES(1,?,?,?)', ('fixture',ts,ts))
sql('INSERT INTO episodes(id,drama_id,episode_number,title,created_at,updated_at) VALUES(1,1,1,?,?,?)', ('fixture',ts,ts))
sql('INSERT INTO ai_service_configs(id,service_type,provider,name,base_url,api_key,model,is_active,created_at,updated_at) VALUES(1,?,?,?,?,?,?,1,?,?)',
('video','minimax','fixture','http://127.0.0.1:18935','test-key',json.dumps(['MiniMax-H3']),ts,ts))
for i in range(1,5):
sql('INSERT INTO storyboards(id,episode_id,storyboard_number,created_at,updated_at) VALUES(?,1,?,?,?)',(i,i,ts,ts))
sql('INSERT INTO sys_task(id,type,storyboard_id,provider,model,status,stage,task_id,config_id,config_base_url,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)',
(i,'video',i,'minimax','MiniMax-H3','processing','running','old-'+str(i),1,'http://127.0.0.1:18935',ts,ts))
# Real local image bytes must reach preflight before cancelling exactly one old job.
result = replace(1,1,expected=201)
for _ in range(100):
if len(events) >= 3: break
time.sleep(.1)
assert [p for p,b in events[:3]] == ['/v2/video_generation/validate','/v2/video_generation/old-1/cancel','/v2/video_generation']
assert events[0][1]['content'] == events[2][1]['content']
assert base64.b64decode(events[0][1]['content'][1]['image_url']['url'].split(',')[1]) == png
assert events[0][1]['gpu'] == events[2][1]['gpu'] == 'gpu3'
assert sql('SELECT error_code FROM sys_task WHERE id=1')[0]['error_code'] == 'user_cancelled'
assert sql('SELECT status FROM sys_task WHERE id=2')[0]['status'] == 'processing'
# Missing reference fails before any upstream call, leaving the old job intact.
before = len(events)
response = replace(2,2,expected=400,body={'reference_image_urls':['static/images/missing.png']})
assert '参考图读取失败' in response['message'] and len(events) == before
assert sql('SELECT status FROM sys_task WHERE id=2')[0]['status'] == 'processing'
# Unready destination also leaves old task intact; no silent duplicate submission.
reject = True; before = len(events)
replace(3,3,expected=400)
assert len(events) == before+1 and events[-1][0].endswith('/validate')
assert sql('SELECT status FROM sys_task WHERE id=3')[0]['status'] == 'processing'
# First-frame replacements use the same conversion and role.
reject = False; before = len(events)
replace(4,4,expected=201,body={'reference_image_urls':[], 'first_frame_url':'/static/images/reference.png'})
for _ in range(100):
if len(events) >= before+3: break
time.sleep(.1)
image = events[before][1]['content'][1]
assert image['role'] == 'first_frame' and image['image_url']['url'].startswith('data:image/')
assert events[before][1]['content'] == events[before+2][1]['content']
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)}))
finally:
if process and process.poll() is None: process.terminate(); process.wait(timeout=10)
server.shutdown(); log.close()
+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'],
'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'],
'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'])