From 3526ed9b31819a997185e0f9080bb1e6e00a9b7a Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 12 Nov 2026 11:06:23 +0800 Subject: [PATCH] Fix GPU replacement preflight with local reference images --- .../backend/src/services/generation.ts | 42 ++++--- .../frontend/app/composables/useToast.ts | 1 + .../test_gpu_replacement_references.py | 112 ++++++++++++++++++ model-bridge/build_upgrade.py | 2 +- 4 files changed, 139 insertions(+), 18 deletions(-) create mode 100644 integration-work/test_gpu_replacement_references.py diff --git a/huobao-source/backend/src/services/generation.ts b/huobao-source/backend/src/services/generation.ts index 829cf0b..7aa2411 100644 --- a/huobao-source/backend/src/services/generation.ts +++ b/huobao-source/backend/src/services/generation.ts @@ -145,13 +145,19 @@ export async function generateVideo(params: GenerateVideoParams): Promise, 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 { 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 diff --git a/huobao-source/frontend/app/composables/useToast.ts b/huobao-source/frontend/app/composables/useToast.ts index 3195d4a..8e86d1a 100644 --- a/huobao-source/frontend/app/composables/useToast.ts +++ b/huobao-source/frontend/app/composables/useToast.ts @@ -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') diff --git a/integration-work/test_gpu_replacement_references.py b/integration-work/test_gpu_replacement_references.py new file mode 100644 index 0000000..7bb619c --- /dev/null +++ b/integration-work/test_gpu_replacement_references.py @@ -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() diff --git a/model-bridge/build_upgrade.py b/model-bridge/build_upgrade.py index 1173654..09b5e7e 100644 --- a/model-bridge/build_upgrade.py +++ b/model-bridge/build_upgrade.py @@ -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'])