"""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()