196 lines
18 KiB
Python
196 lines
18 KiB
Python
"""Isolated bilingual prompt, stale-write, and actual-provider-body checks. No real models/GPU jobs."""
|
|
import base64,concurrent.futures,json,os,pathlib,re,sqlite3,subprocess,threading,time,urllib.request,urllib.error
|
|
from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer
|
|
root=pathlib.Path(__file__).resolve().parent.parent
|
|
data=root/'integration-work'/('scene-lock-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=source.replace('target.hostname === "192.168.110.111" && ["18792", "18794"].includes(target.port)','target.hostname === "127.0.0.1" && target.port === "18951"')
|
|
source=re.sub(r'(url\d*)\.hostname === "192\.168\.110\.111" && \["18792", "18794"\]\.includes\(\1.port\)',r'\1.hostname === "127.0.0.1" && \1.port === "18951"',source)
|
|
source=re.sub(r'&& !!taskRouting\(\{ provider: config\d*\.provider, taskId \}\)','&& !!taskId',source)
|
|
source=source.replace('u.hostname === \"192.168.110.111\" && [\"18792\", \"18794\"].includes(u.port)', 'u.hostname === \"127.0.0.1\" && u.port === \"18951\"')
|
|
(data/'backend.mjs').write_text(source,'utf-8')
|
|
events=[];translations=[];mode='ok';entered=threading.Event();release=threading.Event();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):
|
|
raw=self.rfile.read(int(self.headers['Content-Length']))
|
|
if 'multipart/form-data' in self.headers.get('Content-Type',''):
|
|
from email.parser import BytesParser
|
|
from email.policy import default
|
|
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(len(part.get_payload(decode=True)))
|
|
else:body[name]=part.get_payload(decode=True).decode()
|
|
else:body=json.loads(raw)
|
|
if self.path.endswith('/chat/completions'):
|
|
if body.get('tools'):
|
|
if any(m.get('role')=='tool' for m in body['messages']):message={'role':'assistant','content':'Bilingual storyboard saved.'};finish='stop'
|
|
else:
|
|
name=next(t['function']['name'] for t in body['tools'] if 'save' in t['function']['name'].lower() and 'storyboard' in t['function']['name'].lower())
|
|
en=shot(1)['video_prompt_en']
|
|
args={'storyboards':[{'shot_number':4,'description':'【镜头1】暴雨中,乔雨灵说:「别开门!」【镜头2】镜头靠近四楼宿舍。','video_prompt':shot(1)['video_prompt'],'video_prompt_en':en,'duration':9,'character_ids':[1],'scene_id':1,'continuity_mode':'none'}]}
|
|
message={'role':'assistant','content':None,'tool_calls':[{'id':'save_bilingual_fixture','type':'function','function':{'name':name,'arguments':json.dumps(args)}}]};finish='tool_calls'
|
|
self.reply(200,{'id':'fixture','object':'chat.completion','created':1,'model':'fixture-translator','choices':[{'index':0,'message':message,'finish_reason':finish}],'usage':{'prompt_tokens':1,'completion_tokens':1,'total_tokens':2}});return
|
|
translations.append(body)
|
|
if mode=='unavailable':self.reply(503,{'error':'fixture'});return
|
|
if mode=='delay':entered.set();release.wait(6)
|
|
text='Cinematic rain and camera movement. '+ ' '.join(('Dialogue: '+x if x=='__HB_KEEP_0__' else x) for x in re.findall(r'__HB_KEEP_\d+__',body['messages'][-1]['content']))
|
|
if mode=='invalid':text='Wrong translation without protected tokens'
|
|
self.reply(200,{'choices':[{'message':{'content':json.dumps({'translation':text})}}]});return
|
|
events.append((self.path,body))
|
|
if self.path.endswith('/validate'):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))})
|
|
def do_GET(self):self.reply(200,{'task':{'id':self.path.split('/')[-1],'status':'running','worker_state':'running'}})
|
|
server=ThreadingHTTPServer(('127.0.0.1',18951),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':'18950','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')
|
|
def api(method,path,body=None,expected=200):
|
|
req=urllib.request.Request('http://127.0.0.1:18950/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 r:status=r.status;result=json.load(r)
|
|
except urllib.error.HTTPError as e:status=e.code;result=json.load(e)
|
|
assert status==expected,(path,status,result)
|
|
return result.get('data',result)
|
|
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 shot(i):return sql('select * from storyboards where id=?',(i,))[0]
|
|
def start():
|
|
global process
|
|
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
|
|
try:api('GET','/health');return
|
|
except OSError:time.sleep(.1)
|
|
raise AssertionError('Backend failed to start')
|
|
cn='出场人物:@乔雨灵;场景:@四楼宿舍。\n0-3秒:暴雨中,@乔雨灵 抬头,说:「别开门!」\n3-9秒:镜头缓慢靠近 @四楼宿舍,无字幕。'
|
|
png='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aN1kAAAAASUVORK5CYII='
|
|
def send(i,**extra):
|
|
return api('POST','/tasks',{'type':'video','storyboard_id':i,'config_id':1,'model':'MiniMax-H3','prompt':'0-4s: The girl looks up, frightened, medium close-up.','duration':4,**extra},201)
|
|
def sent_for(task):
|
|
for _ in range(150):
|
|
record=sql('select * from sys_task where id=?',(task['id'],))[0]
|
|
if record['task_id']:return record,events[-1][1]
|
|
assert record['status']!='failed',record['error_msg']
|
|
time.sleep(.1)
|
|
raise AssertionError('Provider never received task')
|
|
try:
|
|
start();ts='2026-11-12T00:00:00Z'
|
|
sql('insert into dramas(id,title,created_at,updated_at) values(1,?,?,?)',('Scene continuity fixture',ts,ts))
|
|
for i in [1,2]:sql('insert into episodes(id,drama_id,episode_number,title,created_at,updated_at) values(?,1,?,?,?,?)',(i,i,'fixture',ts,ts))
|
|
sql("update episodes set content='A girl looks up in the dormitory.', script_content='A girl looks up in the dormitory.'")
|
|
for id,service,provider,model in [(1,'video','minimax','MiniMax-H3'),(2,'text','openai','fixture-translator'),(3,'image','openai','qwen_image_2_1'),(4,'video','localvideo','MiniMax-H3-VDN-H3')]:
|
|
sql('insert into ai_service_configs(id,service_type,provider,name,base_url,api_key,model,is_active,created_at,updated_at) values(?,?,?,?,?,?,?,1,?,?)',(id,service,provider,'fixture','http://127.0.0.1:18951','test-key',json.dumps([model]),ts,ts))
|
|
(data/'static/images').mkdir(parents=True)
|
|
(data/'static/images/scene.png').write_bytes(base64.b64decode(png.split(',')[1]))
|
|
for i,name,layout,lighting in [(1,'Dormitory','Blue plaster walls; one east window; oak desk below the window.','Warm desk lamp from the east.'),(2,'Hallway','Red brick corridor, steel exit door.','Cool overhead strip lights.')]:
|
|
sql('insert into scenes(id,drama_id,location,time,prompt,lighting,image_url,final_prompt,created_at,updated_at) values(?,1,?,?,?,?,?,?,?,?)',(i,name,'night',layout,lighting,'static/images/scene.png','EMPTY SET ONLY. NO PEOPLE. FIXED CAMERA.',ts,ts))
|
|
sql('insert into episode_scenes(episode_id,scene_id,created_at) values(1,?,?)',(i,ts))
|
|
for i in range(1,14):
|
|
sql('insert into storyboards(id,episode_id,storyboard_number,scene_id,description,duration,created_at,updated_at) values(?,?,?,?,?,?,?,?)',(i,2 if i==3 else 1,i,2 if i==4 else 1,'Girl looks up',4,ts,ts))
|
|
# Every request includes the set, even if the user supplied only a performance.
|
|
first,body=sent_for(send(1,reference_image_urls=[png]))
|
|
prompt=body['content'][0]['text']
|
|
assert 'Blue plaster walls' in prompt and 'Warm desk lamp' in prompt and 'EVERY shot and time segment' in prompt
|
|
assert 'Reference image 2' in prompt and 'NO PEOPLE' not in prompt and len(body['content'])==3
|
|
snap=json.loads(first['params'])['sceneLock'];assert snap['referenceIndex']==2 and snap['sceneId']==1
|
|
# Existing numbered references are not shifted, and the scene image is not duplicated.
|
|
second,body=sent_for(send(2,prompt='0-4s: @图片1Girl looks up in @图片2Dormitory.',reference_image_urls=[png,'/static/images/scene.png']))
|
|
assert '@图片1Girl' in body['content'][0]['text'] and '@图片2Dormitory' in body['content'][0]['text'] and len(body['content'])==3
|
|
third,body=sent_for(send(3));assert 'Blue plaster walls' in body['content'][0]['text']
|
|
fourth,body=sent_for(send(4));assert 'Red brick corridor' in body['content'][0]['text'] and 'Blue plaster walls' not in body['content'][0]['text']
|
|
# Same-scene settings can change without modifying old prompts, results or task snapshots.
|
|
api('PUT','/scenes/1',{'continuity_prompt':'Blue plaster walls; bed against north wall; east window; oak desk below it.'})
|
|
assert api('GET','/storyboards/1/environment')['stale'] is True
|
|
assert sql('select prompt from sys_task where id=?',(first['id'],))[0]['prompt']==first['prompt']
|
|
# Overflow and missing/invalid scenes fail before cancelling an existing running job.
|
|
before=len(events)
|
|
api('POST','/tasks',{'type':'video','storyboard_id':1,'config_id':1,'prompt':'test','replace_task_id':first['id'],'reference_image_urls':[png]*9},400)
|
|
assert len(events)==before and sql('select status from sys_task where id=?',(first['id'],))[0]['status']=='processing'
|
|
sql('update storyboards set scene_id=null where id=5')
|
|
api('POST','/tasks',{'type':'video','storyboard_id':5,'prompt':'Only a girl'},400)
|
|
sql('update storyboards set scene_id=999 where id=5')
|
|
api('POST','/tasks',{'type':'video','storyboard_id':5,'prompt':'Only a girl'},400)
|
|
assert len(events)==before
|
|
# Replacement preflight and actual submission must contain the identical locked prompt.
|
|
replacement,body=sent_for(send(1,replace_task_id=first['id'],reference_image_urls=[png],gpu='gpu3'))
|
|
assert [p for p,b in events[before:]]==['/v2/video_generation/validate','/v2/video_generation/'+first['task_id']+'/cancel','/v2/video_generation']
|
|
assert body['content'][0]['text']==events[before][1]['content'][0]['text'] and 'bed against north wall' in body['content'][0]['text']
|
|
# A scene edit during translation cannot cancel the existing job or send stale constraints.
|
|
mode='delay';entered.clear();release.clear();before=len(events)
|
|
with concurrent.futures.ThreadPoolExecutor() as pool:
|
|
f=pool.submit(api,'POST','/tasks',{'type':'video','storyboard_id':1,'prompt':'女孩惊恐地抬头。','config_id':1,'model':'MiniMax-H3','replace_task_id':replacement['id']},400)
|
|
assert entered.wait(5)
|
|
api('PUT','/scenes/1',{'continuity_prompt':'Blue walls; bed north; east window; oak desk east; lamp on desk.'})
|
|
release.set();f.result()
|
|
assert not any(p.endswith('/cancel') for p,b in events[before:])
|
|
assert sql('select status from sys_task where id=?',(replacement['id'],))[0]['status']=='processing'
|
|
mode='ok'
|
|
# Real first-frame extraction still submits exactly one frame and retains written environment.
|
|
(data/'static/videos').mkdir(parents=True)
|
|
subprocess.run([str(stage/'resources/bin/ffmpeg.exe'),'-hide_banner','-loglevel','error','-f','lavfi','-i','color=c=blue:s=320x180:d=2','-c:v','libx264',str(data/'static/videos/source.mp4')],check=True)
|
|
sql("update storyboards set video_url='static/videos/source.mp4' where id=5")
|
|
sql("update storyboards set scene_id=1 where id=5")
|
|
api('PUT','/storyboards/6/continuity',{'continuity_mode':'first_frame','continuity_source_id':5})
|
|
frame,body=sent_for(send(6,reference_image_urls=[png]))
|
|
images=[x for x in body['content'] if x['type']=='image_url']
|
|
assert len(images)==1 and images[0]['role']=='first_frame' and 'Blue walls' in body['content'][0]['text']
|
|
# VDN-HD preserves the original frame and receives exactly one fresh environment block.
|
|
sql("update storyboards set video_url='static/videos/source.mp4' where id=7")
|
|
hd,body=sent_for(api('POST','/hd/storyboards/7',{'config_id':4,'resolution':'2K'},201))
|
|
assert body['model']=='MiniMax-H3-VDN-H3' and len([x for x in body['content'] if x['type']=='image_url'])==1
|
|
assert body['content'][0]['text'].count('[HB_SCENE_LOCK_V1]')==1 and 'Blue walls' in body['content'][0]['text']
|
|
# Storyboard images carry the environment; character portrait generations stay independent.
|
|
picture,body=sent_for(api('POST','/tasks',{'type':'image','storyboard_id':8,'config_id':3,'prompt':'A frightened girl, medium shot','reference_images':[png]},201))
|
|
assert 'Blue walls' in body['prompt'] and len(body['images'])==2 and 'Reference image 2' in body['prompt']
|
|
portrait,body=sent_for(api('POST','/tasks',{'type':'image','character_id':99,'config_id':3,'prompt':'Character sheet on white background'},201))
|
|
assert body['prompt']=='Character sheet on white background'
|
|
# Explicit first-frame requests still reject mixed references. Auto composition is tested separately.
|
|
before=len(events)
|
|
api('POST','/tasks',{'type':'video','storyboard_id':9,'config_id':4,'model':'Wan2.2-I2V','prompt':'test','first_frame_url':'static/images/scene.png','reference_image_urls':[png]},400)
|
|
assert len(events)==before
|
|
single,body=sent_for(send(9,config_id=4,model='Wan2.2-I2V',first_frame_url='static/images/scene.png'))
|
|
assert len([x for x in body['content'] if x['type']=='image_url'])==1 and 'Blue walls' in body['content'][0]['text']
|
|
# LTX screenshot regression: even when the scene is already present, reject 3 refs before admission.
|
|
before=len(events);count=sql('select count(*) n from sys_task')[0]['n']
|
|
failure=api('POST','/tasks',{'type':'video','storyboard_id':12,'config_id':4,'model':'LTX-2.3','prompt':'test','first_frame_url':'static/images/scene.png','reference_image_urls':['static/images/scene.png',png,png]},400)
|
|
assert len(events)==before and sql('select count(*) n from sys_task')[0]['n']==count
|
|
ltx,body=sent_for(send(12,config_id=4,model='LTX-2.3',gpu='gpu3',first_frame_url='static/images/scene.png'))
|
|
assert body['model']=='LTX-2.3' and body['gpu']=='gpu3' and len([x for x in body['content'] if x['type']=='image_url'])==1
|
|
assert 'Blue walls' in body['content'][0]['text']
|
|
# Translation remains English and the immutable layout guard is not lost in the translator output.
|
|
api('PUT','/scenes/1',{'continuity_prompt':'蓝灰色墙面;北侧床铺,东侧窗下书桌。'})
|
|
translated,body=sent_for(send(10))
|
|
assert '蓝灰色墙面' not in body['content'][0]['text'] and 'EVERY shot and time segment' in body['content'][0]['text']
|
|
# A scene without an image still has explicit text constraints and truthful metadata.
|
|
api('PUT','/scenes/2',{'image_url':None})
|
|
sql('update scenes set image_url=null where id=2');sql('update storyboards set scene_id=2 where id=11')
|
|
text_only,body=sent_for(send(11));assert 'No scene image is supplied' in body['content'][0]['text']
|
|
assert json.loads(text_only['params'])['sceneLock']['referenceMode']=='text_only'
|
|
process.terminate();process.wait(10);process=None;start()
|
|
assert sql('select continuity_prompt from scenes where id=1')[0]['continuity_prompt']=='蓝灰色墙面;北侧床铺,东侧窗下书桌。'
|
|
assert sql('select prompt from sys_task where id=?',(first['id'],))[0]['prompt']==first['prompt']
|
|
assert sql('pragma integrity_check')[0]['integrity_check']=='ok'
|
|
print(json.dumps({'passed':True,'fixture':str(data),'checks':['per-request scene injection','scene reference numbering/deduplication','cross-episode shared scene','different scene isolation','before-cancel validation','scene edit race','single-frame continuation','VDN HD','storyboard image reference','portrait isolation','limited-reference model','English visual submission','restart/data preservation']}))
|
|
if os.environ.get('SCENE_UI_TEST'):
|
|
subprocess.run(['node',str(root/'integration-work/browser-test/verify_scene_lock_ui.cjs')],env={**os.environ,'SCENE_FIXTURE':str(data)},check=True)
|
|
if os.environ.get('LTX_UI_TEST'):
|
|
sql('update ai_service_configs set model=? where id=4',(json.dumps(['MiniMax-H3-VDN-H3','LTX-2.3']),))
|
|
for i,name,file in [(1,'Girl','girl.png'),(2,'Other','other.png')]:
|
|
(data/'static/images'/file).write_bytes(base64.b64decode(png.split(',')[1]))
|
|
sql('insert into characters(id,drama_id,name,image_url,created_at,updated_at) values(?,1,?,?,?,?)',(i,name,'static/images/'+file,ts,ts))
|
|
sql('insert into episode_characters(episode_id,character_id,created_at) values(1,?,?)',(i,ts))
|
|
sql('insert into storyboard_characters(storyboard_id,character_id) values(13,?)',(i,))
|
|
sql("update storyboards set title='Reference fixture',description='Reference fixture',video_prompt='0-4s: @Dormitory, @Girl looks at @Other.' where id=13")
|
|
subprocess.run(['node',str(root/'integration-work/browser-test/verify_video_reference_ui.cjs')],env={**os.environ,'SCENE_FIXTURE':str(data)},check=True)
|
|
finally:
|
|
if process:process.terminate();process.wait(10)
|
|
server.shutdown();log.close()
|