"""End-to-end isolated image->video lifecycle tests. Never calls live models or the live DB.""" import base64, hashlib, json, os, pathlib, re, sqlite3, struct, subprocess, threading, time, urllib.request, urllib.error, zlib from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from email.parser import BytesParser from email.policy import default root=pathlib.Path(__file__).resolve().parent.parent data=root/'integration-work'/('composed-frame-test-'+time.strftime('%Y%m%d-%H%M%S'));data.mkdir() stage=root/'integration-work/stage-4.0.11/app';dbfile=data/'huobao.sqlite3' 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) source=re.sub(r'(\w+)\.hostname === "192\.168\.110\.111" && \["18792", "18794"\]\.includes\(\1.port\)',r'\1.hostname === "127.0.0.1" && \1.port === "18971"',source) source=re.sub(r'&& !!taskRouting\(\{ provider: config\d*\.provider, taskId \}\)','&& !!taskId',source) source=source.replace('intervalMs: 1e4','intervalMs: 100').replace('intervalMs: 5e3','intervalMs: 100') (data/'backend.mjs').write_text(source,'utf-8') def png(color): def chunk(tag,raw):return struct.pack('>I',len(raw))+tag+raw+struct.pack('>I',zlib.crc32(tag+raw)&0xffffffff) return b'\x89PNG\r\n\x1a\n'+chunk(b'IHDR',struct.pack('>IIBBBBB',32,18,8,2,0,0,0))+chunk(b'IDAT',zlib.compress((b'\0'+bytes(color)*32)*18))+chunk(b'IEND',b'') events=[];image_jobs={};image_mode='complete';image_submit_release=threading.Event();image_submit_release.set();video_submit_release=threading.Event();video_submit_release.set() class Mock(BaseHTTPRequestHandler): def log_message(self,*args):pass def reply(self,status,body): try: raw=json.dumps(body).encode();self.send_response(status);self.send_header('Content-Type','application/json');self.end_headers();self.wfile.write(raw) except (BrokenPipeError,ConnectionResetError):pass def do_POST(self): raw=self.rfile.read(int(self.headers.get('Content-Length','0'))) if 'multipart/form-data' in self.headers.get('Content-Type',''): message=BytesParser(policy=default).parsebytes(('Content-Type: '+self.headers['Content-Type']+'\r\nMIME-Version: 1.0\r\n\r\n').encode()+raw) body={'images':[]} for part in message.iter_parts(): name=part.get_param('name',header='content-disposition') if name=='image[]':body['images'].append(hashlib.sha256(part.get_payload(decode=True)).hexdigest()) else:body[name]=part.get_payload(decode=True).decode() else:body=json.loads(raw or b'{}') events.append((self.path,body)) if self.path.endswith('/images/edits'): jid='img-'+str(len(image_jobs)+1);image_jobs[jid]={'status':image_mode,'color':(len(image_jobs)*11%200,120,210)} image_submit_release.wait(25);self.reply(200,{'task_id':jid,'status':'processing'});return if '/images/task/' in self.path and self.path.endswith('/cancel'): jid=self.path.split('/')[-2];image_jobs[jid]['status']='cancelled';self.reply(200,{'task':{'id':jid,'status':'cancelled'}});return if self.path.endswith('/validate'):self.reply(200,{'valid':True});return if self.path.endswith('/cancel'):self.reply(200,{'task':{'id':self.path.split('/')[-2],'status':'cancelled'}});return if self.path.endswith('/video_generation'): video_submit_release.wait(25);self.reply(200,{'task_id':'vid-'+str(len(events))});return if self.path.endswith('/chat/completions'): content='An establishing shot. '+' '.join(re.findall(r'__HB_KEEP_\d+__',body['messages'][-1]['content'])) self.reply(200,{'choices':[{'message':{'content':json.dumps({'translation':content})}}]});return self.reply(400,{'error':'unexpected fixture route'}) def do_GET(self): if self.path.startswith('/image/'): raw=png(image_jobs[self.path.split('/')[-1]]['color']);self.send_response(200);self.send_header('Content-Type','image/png');self.end_headers();self.wfile.write(raw);return if '/images/task/' in self.path: jid=self.path.split('/')[-1];state=image_jobs[jid]['status'] if state=='complete':self.reply(200,{'status':'completed','image_url':'http://127.0.0.1:18971/image/'+jid}) elif state in ('fail','cancelled'):self.reply(200,{'status':'failed','cancelled':state=='cancelled','error':{'message':'Fixture image failure'}}) else:self.reply(200,{'status':'processing','worker_state':'queued'}) return self.reply(200,{'task':{'id':self.path.split('/')[-1],'status':'running','worker_state':'running'}}) server=ThreadingHTTPServer(('127.0.0.1',18971),Mock);threading.Thread(target=server.serve_forever,daemon=True).start() env={**os.environ,'FFMPEG_BIN':str(stage/'resources/bin/ffmpeg.exe'),'FFPROBE_BIN':str(stage/'resources/bin/ffprobe.exe'),'ELECTRON_RUN_AS_NODE':'1','PORT':'18970','HUOBAO_DATA_DIR':str(data),'SQLITE_PATH':str(dbfile),'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');process=None def api(method,path,body=None,expected=200): req=urllib.request.Request('http://127.0.0.1:18970/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(req,timeout=30) as response:status=response.status;value=json.load(response) except urllib.error.HTTPError as error:status=error.code;value=json.load(error) assert status==expected,(path,status,value) return value.get('data',value) def sql(query,args=()): with sqlite3.connect(dbfile) as db:db.row_factory=sqlite3.Row;return [dict(r) for r in db.execute(query,args)] def task(id):return sql('select * from sys_task where id=?',(id,))[0] def plan(id):return json.loads(task(id)['params'])['firstFrameComposition'] def wait(fn,seconds=15): deadline=time.monotonic()+seconds while time.monotonic() route's old 9-image limit) reach Qwen over 3 rounds. for c in range(3,14):sql('insert into storyboard_characters(storyboard_id,character_id) values(3,?)',(c,)) image_mode='complete';before=len(images());many=send(3,reference_image_urls=[f'static/images/input{i}.png' for i in range(15)]) multi=finished_frame(many);assert len(multi['assets'])==15 and multi['rounds']==3 and len(multi['images'])==3 assert [len(x['images']) for x in images()[before:]]==[6,6,5] originals=[] for child_id in multi['childTaskIds']:originals+=json.loads(task(child_id)['params'])['referenceAssets'] assert originals==multi['assets'] and len(videos()[-1]['content'])==2 # Missing references fail before replacement touches an existing running video. sql("update characters set image_url=null where id=2");before=len(events) api('POST','/tasks',{'type':'video','storyboard_id':1,'model':'LTX-2.3','config_id':1,'prompt':'test','replace_task_id':two,'force_new':True},400) assert len(events)==before and task(two)['status']=='processing' sql("update characters set image_url='static/images/input2.png' where id=2") # Image failure cannot emit a video or overwrite source assets. image_mode='fail';before_v=len(videos());failed=send(4) wait(lambda:task(failed)['status']=='failed');assert '首帧合成' in task(failed)['error_msg'] and len(videos())==before_v # Edits during generation keep the old output in history but prohibit stale video submission. image_mode='hold';stale=send(5);wait(lambda:plan(stale)['childTaskIds']);child=plan(stale)['childTaskIds'][0];wait(lambda:task(child)['task_id']) sql("update storyboards set description='Changed pose' where id=5");image_jobs[task(child)['task_id']]['status']='complete' wait(lambda:task(stale)['status']=='failed');assert '已变化' in task(stale)['error_msg'] and len(videos())==before_v assert task(child)['local_path'] and not sql('select first_frame_image from storyboards where id=5')[0]['first_frame_image'] # Restart follows the SAME image task; no duplicate image or video generation. restart=send(6);wait(lambda:plan(restart)['childTaskIds']);child=plan(restart)['childTaskIds'][0];wait(lambda:task(child)['task_id']) jid=task(child)['task_id'];before=len(images());stop();start();assert task(child)['task_id']==jid image_jobs[jid]['status']='complete';finished_frame(restart);assert len(images())==before # Cancel while the image POST has not returned its ID. Wait for acknowledgement, then cancel exactly that job. image_submit_release.clear();image_mode='hold';pending=send(7);wait(lambda:len(images())==before+1) assert api('POST',f'/tasks/{pending}/cancel',{})['outcome']=='pending' image_submit_release.set();wait(lambda:task(pending)['error_code']=='user_cancelled') assert image_jobs['img-'+str(len(image_jobs))]['status']=='cancelled' # Previous-shot tail frame is an EXTRA reference, never a replacement for bound assets. (data/'static/videos').mkdir(exist_ok=True) subprocess.run([str(stage/'resources/bin/ffmpeg.exe'),'-hide_banner','-loglevel','error','-f','lavfi','-i','color=c=blue:s=320x192:d=1','-c:v','libx264',str(data/'static/videos/source.mp4')],check=True,creationflags=subprocess.CREATE_NO_WINDOW) sql("update storyboards set video_url='static/videos/source.mp4',continuity_end='At the east window' where id=20") api('PUT','/storyboards/21/continuity',{'continuity_mode':'first_frame','continuity_source_id':20,'continuity_start':'At the east window'}) image_mode='complete';before=len(images());continued=send(21,model='Wan2.2-I2V');continued_plan=finished_frame(continued) assert continued_plan['continuityFrameUrl'] and len(images()[before]['images'])==5 assert len(continued_plan['assets'])==4 and continued_plan['frameUrl']!=continued_plan['continuityFrameUrl'] assert len([x for x in videos()[-1]['content'] if x['type']=='image_url'])==1 # A crash after a video POST may have reached the provider: never submit a duplicate. before=len(videos());video_submit_release.clear();unknown=send(22);wait(lambda:len(videos())==before+1) assert task(unknown)['stage']=='submitting' and not task(unknown)['task_id'];stop();start() wait(lambda:task(unknown)['error_code']=='submission_unknown');video_submit_release.set();assert len(videos())==before+1 # Explicit regeneration bypasses the valid frame cache, then supports normal cancellation. image_mode='hold';before=len(images());fresh=send(1,replace_task_id=two,regenerate_first_frame=True) wait(lambda:len(images())==before+1);assert task(two)['error_code']=='user_cancelled';wait(lambda:task(plan(fresh)['childTaskIds'][0])['task_id']) api('POST',f'/tasks/{fresh}/cancel',{}) # Existing direct multi-reference H3 flow must not create an image task. image_mode='complete';before=len(images());h3=send(8,model='MiniMax-H3',reference_image_urls=['static/images/input0.png','static/images/input1.png']) wait(lambda:task(h3)['task_id']);assert len(images())==before and len([x for x in videos()[-1]['content'] if x['type']=='image_url'])==2 # The default selected video config also accepts every binding when model is omitted. for c in range(3,14):sql('insert into storyboard_characters(storyboard_id,character_id) values(23,?)',(c,)) default_task=send(23,model=None,reference_image_urls=[f'static/images/input{i}.png' for i in range(15)]) assert len(finished_frame(default_task)['assets'])==15 # No source artwork or their metadata was changed by composition. assert all(sql('select * from '+name+' order by id')==snapshot for name,snapshot in sources.items()) assert sql('pragma integrity_check')[0]['integrity_check']=='ok' report={'passed':True,'fixture':str(data),'checks':['all bound references','image before video','one final frame sent','correct image and video GPUs','15 refs over 3 rounds','cache reuse','forced remake','missing-reference before cancellation','image failure isolation','stale-edit protection','restart no duplicate','pending submission cancellation','tail-frame plus all bound images','unknown video acknowledgement no resubmission','default model all refs','H3 unchanged','source artwork unchanged']} (data/'result.json').write_text(json.dumps(report,indent=2));print(json.dumps(report)) if os.environ.get('COMPOSE_UI_TEST'): sql("update storyboards set title='Composition fixture shot' where id=23") sql("update sys_task set status='failed',error_code='generation_failed' where id=?",(default_task,)) subprocess.run(['node',str(root/'integration-work/browser-test/verify_composed_frame_ui.cjs')],env={**os.environ,'COMPOSE_FIXTURE':str(data)},check=True) finally: image_submit_release.set();video_submit_release.set() if process:stop() server.shutdown();log.close()