Files
dj/integration-work/test_scene_continuity.py
2026-11-13 11:46:25 +08:00

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