Initial commit: RHINE LAB · ANALYSIS OS:三维界面与动效实验(本仓库不含个人归档索引数据)

This commit is contained in:
WpyQwq
2026-09-19 13:04:56 +08:00
commit 3f422218a5
1098 changed files with 39410 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
# 档案内容与设定来源
40 份正文为原创的档案式改写,研究记录中包含围绕设定提出的观察重点,并非游戏原文、官方实验结论或真实世界研究建议。编号仅用于本站编目。未核实的日期改为“跨期资料汇编”;人物字段标为“相关人物”,不虚构项目任命。卡片页及文本导出均保留对应来源。
核对重点:多萝西属于源石技艺应用科;斐尔迪南与能量科相关;结构科的研究内容不能与一般承重结构工程混同。事件和人物经历跨越多个阶段,避免把历史职务写成一律同时在任。
## 参考资料
- [rhine](https://arknights.wiki.gg/wiki/Rhine_Lab)
- [chronicle](https://arknights.wiki.gg/wiki/Rhine_Lab_Research_Report)
- [engineer](https://prts.wiki/w/莱茵生命高级工程科成员)
- [armor](https://prts.wiki/w/R-31重型动力装甲)
- [silence](https://prts.wiki/w/赫默)
- [paradigm](https://prts.wiki/w/淬羽赫默)
- [saria](https://prts.wiki/w/塞雷娅)
- [mayer](https://prts.wiki/w/梅尔)
- [magallan](https://prts.wiki/w/Magallan)
- [muelsyse](https://prts.wiki/w/缪尔赛思)
- [ptilopsis](https://prts.wiki/w/白面鸮)
- [dorothy](https://prts.wiki/w/多萝西)
- [ifrit](https://prts.wiki/w/Ifrit)
- [lone](https://arknights.wiki.gg/wiki/Lone_Trail)
- [loneEvent](https://prts.wiki/w/孤星2024)
- [investigation](https://arknights.wiki.gg/wiki/Lone_Trail/Investigation_Reports)
PRTS 页面主要用来核对收录的游戏档案、履历与活动说明;Terra Wiki 的调查资料及活动页用于补充沿革和事件关联。正文采用摘要与独立分析,没有复制长段原文。
+19
View File
@@ -0,0 +1,19 @@
import sys
sys.path.insert(0,'.tools')
import cv2
from PIL import Image, ImageDraw
from pathlib import Path
out=Path('reference/motion');out.mkdir(parents=True,exist_ok=True)
v=cv2.VideoCapture(str(next(Path('.').glob('*.mp4'))));fps=v.get(cv2.CAP_PROP_FPS)
v.set(cv2.CAP_PROP_POS_FRAMES,26*25)
for frame in range(650,1001):
ok,im=v.read()
if not ok:break
cv2.imwrite(str(out/f'{frame:04}.jpg'),im,[cv2.IMWRITE_JPEG_QUALITY,88])
for start in [27,29,31,33,35,37]:
sheet=Image.new('RGB',(1920,1536),'#282828');d=ImageDraw.Draw(sheet)
for i in range(12):
fr=round((start+i/6)*25);im=Image.open(out/f'{fr:04}.jpg');im.thumbnail((640,360));x=(i%3)*640;y=(i//3)*384
sheet.paste(im,(x,y+24));d.text((x+12,y+5),f'{fr/25:.2f}s FRAME {fr}',fill='white')
sheet.save(out/f'sequence-{start}.jpg',quality=94)
print('Extracted 351 consecutive frames at 25fps and 6 contact sheets')
+54
View File
@@ -0,0 +1,54 @@
{
"video": "《明日方舟》特别映像 [莱茵生命:访问] [BV1rr4y1b7sz].mp4",
"scope": "6.76–40 s, mono downmix; not a transcription or melody identification",
"stages": [
{
"start": 6.76,
"end": 9.16,
"peakDb": -6.33,
"rmsDb": -24.15
},
{
"start": 9.16,
"end": 11.12,
"peakDb": -13.47,
"rmsDb": -36.6
},
{
"start": 11.12,
"end": 19.48,
"peakDb": -4.4,
"rmsDb": -25.29
},
{
"start": 19.48,
"end": 22.76,
"peakDb": -4.46,
"rmsDb": -25.71
},
{
"start": 22.76,
"end": 26.92,
"peakDb": -3.95,
"rmsDb": -15.92
},
{
"start": 26.92,
"end": 30.68,
"peakDb": -1.76,
"rmsDb": -13.05
},
{
"start": 30.68,
"end": 33.3,
"peakDb": -1.85,
"rmsDb": -13.71
},
{
"start": 33.3,
"end": 40,
"peakDb": -2.34,
"rmsDb": -13.15
}
]
}
+78
View File
@@ -0,0 +1,78 @@
<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>声音验证 · Rhine Lab</title>
<style>body{max-width:980px;margin:40px auto;padding:0 24px;background:#eee9df;color:#232721;font:16px system-ui;line-height:1.6}button{padding:12px 24px;margin:8px 12px 8px 0}pre{white-space:pre-wrap;font-size:13px}audio{width:100%}video{width:100%;max-height:380px}a{color:inherit}</style>
<h1>声音验证与试听</h1><p>原创配乐「观测室」· 72 BPM · 53.33 秒循环。下方为固定混音;主程序随浏览、阅读和模型查看调整三条声部。</p>
<p><a href="boot-audio.html">播放完整开场(含原片输入音效) →</a></p>
<p>卡片动作使用玻璃轻碰;窗口、标志、权限和整块文字使用系统电子反馈;逐字输入使用原片短音。可先比较「打开窗口」「启动标志」「文字显现」,再听卡片动作。</p>
<section aria-label="逐字输入对照"><h2>逐字输入 · 原片复刻</h2><p>原片输入片段(6.84–7.39 秒,已匹配试听音量)</p><audio controls aria-label="原片逐字输入片段" src="/reference/typing-original.wav"></audio><p>复刻版(原片三个短音轮替,两个输入串与中间停顿)</p><audio controls aria-label="复刻逐字输入" src="/audio/typing-preview.wav"></audio></section>
<h2>背景音乐与单项音效</h2>
<audio controls src="/audio/observatory-preview.mp3"></audio>
<p id="audition" aria-label="音效试听"></p>
<button id="test">运行音频检查</button><button id="stop">停止测试声音</button><a href="/?scene=archive">进入终端</a>
<pre id="result">等待检查。点击后会解码音乐并在离线音频上下文中检查信号。</pre>
<details><summary>原片对照(6.76–40 秒)</summary><video controls preload="metadata" src="/《明日方舟》特别映像 [莱茵生命:访问] [BV1rr4y1b7sz].mp4#t=6.76,40"></video></details>
<script type="module">
import {TerminalAudio,synthesizeSound,BOOT_CUES} from '/src/audio.ts';
import {TYPING_FRAMES,hasTypingBetween} from '/src/typing-rhythm.ts';
let terminal=new TerminalAudio();
const output=document.querySelector('#result');let running=false;
const sounds={'page-open':'打开窗口','page-close':'关闭窗口','ui-tick':'系统操作',brand:'启动标志','text-reveal':'文字显现',confirm:'权限确认',scan:'权限扫描',welcome:'欢迎页',inspect:'读取标记',key:'逐字输入',tick:'切换档案',column:'切换列',open:'抽取档案',back:'归位返回',array:'阵列进入',explode:'拆解',assemble:'重组'};
document.querySelector('#audition').innerHTML=Object.entries(sounds).map(([key,name])=>`<button data-cue="${key}">${name}</button>`).join('');
document.querySelector('#audition').onclick=async e=>{const cue=e.target.dataset.cue;if(!cue||running)return;terminal.configure({sound:true,music:false,soundVolume:.55,musicVolume:0});await delay(40);terminal.play(cue);};
const delay=ms=>new Promise(r=>setTimeout(r,ms));
const line=s=>output.textContent+='\n'+s;
const check=(ok,text)=>{if(!ok)throw Error(text);line('PASS '+text);};
document.querySelector('#stop').onclick=()=>{terminal.configure({sound:false,music:false,soundVolume:.55,musicVolume:.5});};
document.querySelector('#test').onclick=async()=>{
if(running)return;running=true;output.textContent='正在验证…';
try{
terminal.configure({sound:true,music:true,soundVolume:0,musicVolume:0});
for(let i=0;i<200&&!terminal.stats().loaded&&!terminal.stats().error;i++)await delay(50);
check(terminal.stats().loaded, '音乐加载成功 '+terminal.stats().error);
check(terminal.stats().tracks===3,'三条声部同时播放');
for(const sound of Object.keys(sounds)){
const c=new OfflineAudioContext(2,48000*3,48000);synthesizeSound(c,c.destination,sound,.02,.4);
const b=await c.startRendering();let peak=0,sum=0,last=0,delta=0;
for(const x of b.getChannelData(0)){checkFinite(x);peak=Math.max(peak,Math.abs(x));sum+=x*x;delta=Math.max(delta,Math.abs(x-last));last=x;}
check(peak>.001&&peak<.8&&sum>0&&Math.abs(last)<.00001&&delta<.2,`${sound} 有效信号、无削波、尾端归零(峰值 ${peak.toFixed(3)})`);
}
const rapidContext=new OfflineAudioContext(2,48000*4,48000);
for(let i=0;i<40;i++)synthesizeSound(rapidContext,rapidContext.destination,i%2?'tick':'column',.02+i*.055,i%2?.4:-.4);
const rapidBuffer=await rapidContext.startRendering();let rapidPeak=0;
for(let channel=0;channel<2;channel++)for(const x of rapidBuffer.getChannelData(channel)){checkFinite(x);rapidPeak=Math.max(rapidPeak,Math.abs(x));}
check(rapidPeak<.3,`40 次连续玻璃碰击保留输出余量(峰值 ${rapidPeak.toFixed(3)})`);
const typingContext=new OfflineAudioContext(2,48000*2,48000);
for(let i=0;i<25;i++)synthesizeSound(typingContext,typingContext.destination,'key',.02+i*.04);
const typingBuffer=await typingContext.startRendering();let typingPeak=0;
for(const x of typingBuffer.getChannelData(0)){checkFinite(x);typingPeak=Math.max(typingPeak,Math.abs(x));}
check(typingPeak>.01&&typingPeak<.15,`25Hz 连续输入无削波(峰值 ${typingPeak.toFixed(3)})`);
for(const start of [6.8,11.28,12.8,14.68,16.92,17.96])check(hasTypingBetween(start-.01,start),`字段首字 ${start}s 有输入反馈`);
check(!hasTypingBetween(18.9,19.48),'闪动后文字恢复不产生输入声');
for(const name of ['atmosphere','motif','pulse']){
const c=new OfflineAudioContext(2,48000,48000),b=await c.decodeAudioData(await(await fetch(`/audio/${name}.ogg`)).arrayBuffer());
check(Math.abs(b.duration-160/3)<.002,`${name} 循环时长准确`);
for(let channel=0;channel<2;channel++){
const x=b.getChannelData(channel);let peak=0;for(const sample of x){checkFinite(sample);peak=Math.max(peak,Math.abs(sample));}
check(peak<.9&&Math.abs(x[0]-x[x.length-1])<.01,`${name} 声道 ${channel+1} 无削波、接缝 < 0.01`);
}
}
const cueSounds=[];const tracker=new TerminalAudio();tracker.play=s=>cueSounds.push(s);
for(let t=1.76;t<35;t+=.02)tracker.updateBoot(t);
check(BOOT_CUES.every(c=>cueSounds.includes(c.sound)),'启动关键事件全部触发');
check(cueSounds.filter(s=>s==='key').length===TYPING_FRAMES.length,'逐字显示帧无漏触发或重复触发');
const count=cueSounds.length;tracker.updateBoot(1.76);tracker.updateBoot(29,true);tracker.updateBoot(10,true);
check(cueSounds.length===count,'倒退、跳转、冻结不补播历史声音');tracker.dispose();
for(let i=0;i<100;i++)terminal.play(i%2?'tick':'column');
check(terminal.stats().voices<=10,'连续输入的音效数量受限');
terminal.setScene('detail');check(terminal.stats().tracks===3&&terminal.stats().scene==='detail','详情切换保留音乐轨道');
terminal.configure({sound:true,music:false,soundVolume:0,musicVolume:0});check(terminal.stats().tracks===0,'关闭音乐保留独立音效');
for(let i=0;i<8;i++) {terminal.configure({sound:false,music:true,soundVolume:0,musicVolume:0});terminal.configure({sound:false,music:false,soundVolume:0,musicVolume:0});}
await delay(150);check(terminal.stats().tracks===0,'快速开关不产生残留音乐');
terminal.configure({sound:true,music:true,soundVolume:0,musicVolume:0});await delay(200);
check(terminal.stats().tracks===3,'重新开启恰好恢复三轨');
terminal.configure({sound:false,music:false,soundVolume:0,musicVolume:0});await delay(100);
check(terminal.stats().state==='suspended'&&terminal.stats().voices===0,'全静音暂停上下文并清空音效');
line('全部检查通过。听感仍请用耳机和扬声器试听确认。');
}catch(e){line('FAIL '+e.message);}finally{running=false;terminal.configure({sound:false,music:false,soundVolume:0,musicVolume:0});}
};
function checkFinite(x){if(!Number.isFinite(x))throw Error('非有限音频样本');}
</script></html>
+103
View File
@@ -0,0 +1,103 @@
// Times are seconds from the reference video's 05:00 frame (25 fps).
export const smooth = (t: number) => {
t = Math.max(0, Math.min(1, t));
return t * t * t * (10 + t * (-15 + 6 * t));
};
const bell = (x: number, width: number) => Math.exp(-0.5 * (x / width) ** 2);
export function archiveWave(row: number, lane: number, time: number) {
const t = time - 22;
const phase = row + (lane - 2) * 0.65;
const enter = smooth(t / 0.32);
const first = 3 + t * 19;
const returning = 32 - (t - 2.3) * 24;
// A crest has shoulders and a trailing trough: neighboring files describe
// one continuous surface, rather than independent staggered tweens.
const packet = (distance: number) =>
2.5 * bell(distance, 3.8) - 0.58 * bell(distance - 6, 3.5);
return (
enter *
(packet(phase - first) * (1 - smooth((t - 2.15) / 0.65)) +
packet(phase - returning) *
smooth((t - 2.17) / 0.32) *
(1 - smooth((t - 3.5) / 0.85)))
);
}
export function extraction(time: number) {
return (
0.4 * smooth((time - 25.58) / 0.82) + 2.95 * smooth((time - 27.55) / 1.3)
);
}
// The returning scan leaves two moving shoulders around the selected file.
// Their delay grows with distance, so the neighboring files keep moving during
// the first extraction and settle before the second extraction.
export function settlingWave(distance: number, time: number) {
const age = time - 25.05 - Math.abs(distance) * 0.065;
const envelope = Math.max(
-0.42,
2.15 - 0.17 * (Math.sqrt(distance * distance + 1) - 1),
);
const rise = smooth(age / 0.62);
const ring = age > 0 ? Math.sin(age * 5.1) * Math.exp(-age * 1.3) : 0;
return envelope * (rise + 0.18 * ring * smooth(age / 0.16));
}
export function selectionWave(distance: number, age: number) {
if (age < 0 || age > 3.2) return 0;
return (
0.8 *
smooth(age / 0.2) *
Math.exp(-age * 1.15) *
Math.cos((distance - age * 8) * 0.58) *
bell(distance - age * 8, 3.4)
);
}
export function columnStrength(lane: number, focus: number, progress = 1) {
const selected = 0.25 + 0.75 * bell(lane - focus, 0.55);
return 1 + (selected - 1) * smooth(progress);
}
// A quiet idle drift, with neighboring cards slightly out of phase.
// Maximum displacement is 0.102, under 3% of a card's height.
export function idleWave(row: number, lane: number, time: number) {
return (
0.075 * Math.sin((time * Math.PI * 2) / 8 + row * 0.3 - lane * 0.45) +
0.027 * Math.sin((time * Math.PI * 2) / 13 - row * 0.17 + lane * 0.3)
);
}
export function cinematicField(
row: number,
lane: number,
time: number,
center = 12,
focus = 2,
) {
const handoff = smooth((time - 24.95) / 0.45);
const selection = smooth((time - 25.4) / 0.95);
const shoulderTime = time + 0.3 * handoff * (1 - selection);
return (
archiveWave(row, lane, time) * (1 - handoff) +
settlingWave(row - center, shoulderTime) *
columnStrength(lane, focus, (time - 25.4) / 0.95)
);
}
export const INSPECTION_LIFT = 4.05;
export const ALIGNMENT_EPSILON = 0.001;
// Hold altitude while facing back into the slot. Descent begins only once
// alignment is complete; this also applies to independently returning copies.
export function returnStep(angle: number, dt: number, reduced = false) {
const next = angle * Math.exp(-dt * (reduced ? 35 : 7));
return Math.abs(next) <= ALIGNMENT_EPSILON ? 0 : next;
}
export interface Spring {
value: number;
velocity: number;
}
export function damp(s: Spring, target: number, rate: number, dt: number) {
const delta = s.value - target;
const impulse = s.velocity + rate * delta;
const decay = Math.exp(-rate * dt);
s.value = target + (delta + impulse * dt) * decay;
s.velocity = (s.velocity - rate * impulse * dt) * decay;
}
+51
View File
@@ -0,0 +1,51 @@
<!doctype html>
<html lang="zh-CN">
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>完整开场 · 原片输入音效</title>
<style>
*{box-sizing:border-box}body{margin:0;background:#eee9df;color:#272b25;font:14px system-ui;line-height:1.6}main{max-width:1600px;margin:auto;padding:24px}h1{font-size:22px;margin:0 0 8px}p{color:#67685f;margin:0 0 16px}nav{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-bottom:16px}button,a{font:inherit;border:1px solid #b6b8a9;color:inherit;background:transparent;padding:9px 16px;text-decoration:none;cursor:pointer}button:disabled{opacity:.45;cursor:wait}button:focus-visible,a:focus-visible{outline:2px solid #776343;outline-offset:3px}label{display:flex;align-items:center;gap:8px}input{accent-color:#565c46}iframe{display:block;width:100%;aspect-ratio:16/9;border:0;background:#eae5e1}output{margin-left:auto;font-variant-numeric:tabular-nums}details{margin-top:18px;color:#65685c}pre{white-space:pre-wrap;font:12px monospace}
</style>
<main><h1>完整开场 · 原片输入音效</h1>
<p>从白场开始播放真实界面。开场文字使用原片输入短音,随后衔接权限验证、欢迎页和档案阵列。</p>
<nav><button id="play" disabled>播放完整开场(含音效)</button><button id="stop" disabled>停止并进入档案</button><label><input id="music" type="checkbox">包含背景音乐</label><a href="audio-check.html">单项音效试听</a><output id="status" role="status">正在载入场景…</output></nav>
<iframe id="replica" title="完整开场实时界面" allow="autoplay" src="/?scene=archive"></iframe>
<details><summary>同步检查</summary><pre id="checks">等待播放。</pre></details>
</main>
<script type="module">
import {TYPING_FRAMES} from '/src/typing-rhythm.ts';
const frame=document.querySelector('#replica'),play=document.querySelector('#play'),stop=document.querySelector('#stop'),status=document.querySelector('#status'),checks=document.querySelector('#checks');
let running=false,starting=false,baseline=0,started=false,done=false,lastPoll=0,request=0;
const api=()=>frame.contentWindow?.rhine;
play.onclick=async()=>{
const app=api();if(!app?.stats().ready||starting)return;
const ticket=++request;
baseline=app.stats().audio.playedKeys;done=false;starting=true;
play.disabled=true;stop.disabled=false;document.querySelectorAll('audio').forEach(a=>a.pause());
status.textContent='正在准备声音…';
// Invoke while the click's activation is still available to the same-origin iframe.
const ok=await app.playBootPreview(document.querySelector('#music').checked);
if(ticket!==request)return;
starting=false;running=ok;started=ok;
status.textContent=ok?'正在播放':'声音未能启动,请再次点击播放。';
};
stop.onclick=()=>{request++;api()?.archive();running=false;starting=false;done=false;status.textContent='已停止,进入档案。';stop.disabled=true;};
function poll(now){
if(now-lastPoll>100){
lastPoll=now;const stats=api()?.stats();
if(stats?.ready){
play.disabled=starting;play.textContent=started?'从头重播(含音效)':'播放完整开场(含音效)';
if(!started&&!starting)status.textContent='已就绪,点击播放。';
if(running&&stats.mode==='boot')status.textContent=`正在播放 · 原片 ${stats.bootTime.toFixed(2)} 秒`;
if(running&&stats.mode!=='boot'){running=false;done=true;status.textContent='开场结束,已进入档案。';stop.disabled=true;}
if(started){
const count=stats.audio.playedKeys-baseline;
checks.textContent=JSON.stringify({mode:stats.mode,time:stats.bootTime,inputSounds:count,expectedInputSounds:TYPING_FRAMES.length,
audioState:stats.audio.state,voices:stats.audio.voices,tracks:stats.audio.tracks,
soundEnabled:stats.audio.preferences.sound,complete:done,allInputFramesPlayed:done?count===TYPING_FRAMES.length:null},null,2);
}
}
}
requestAnimationFrame(poll);
}
requestAnimationFrame(poll);
</script></html>
+55
View File
@@ -0,0 +1,55 @@
<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>前段动效检查</title>
<style>body{font:15px system-ui;background:#e8e5e1;padding:20px}iframe{width:640px;height:360px;border:0}pre{white-space:pre-wrap}</style>
<h1>前段动效检查</h1><pre id="result">正在检查…</pre><iframe id="app" src="/?time=1.76&freeze=1&review=1"></iframe>
<script type="module">
import {bootMotion} from '/src/boot-motion.ts';
const result=document.querySelector('#result'), app=document.querySelector('#app'), checks=[];
const check=(name,condition)=>{checks.push({name,passed:!!condition});if(!condition)throw Error(name)};
const state=frame=>bootMotion(frame/25-5);
const wait=async predicate=>{const start=performance.now();while(!predicate()){if(performance.now()-start>30000)throw Error('等待页面超时');await new Promise(r=>setTimeout(r,40))}};
try {
check('白场后第一帧为空,下一帧开始输入 A',state(169).access===''&&state(170).access==='A');
check('ACCESS 在第 175 帧完成,整句在第 187 帧完成',state(175).access==='ACCESS'&&state(187).access==='ACCESS PERMISSION REQUIRED');
check('身份确认先停顿,再输入姓名',state(300).auth==='ID CONFIRMED'&&state(339).auth==='ID CONFIRMED : JOYCE MOORE');
check('请求接收与开始处理之间确实清空',state(389).auth==='REQUEST RECEIVED'&&state(421).auth===''&&state(440).auth==='START PROCESSING');
check('圆环收拢中保留第 525、526、528、529 帧跳变',state(525).ringScale>1&&state(526).ringScale>1&&state(527).ringScale===1&&state(528).ringScale>1&&state(530).ringScale===1);
check('圆环中心切换与欢迎页切换各自落在指定帧',!state(545).ornament&&state(546).ornament&&state(568).scanVisible&&!state(569).scanVisible&&state(569).welcomeVisible);
check('欢迎闪切、黑底长尾扫过、第三行分三次出现',state(569).welcomePanel===1&&state(570).welcomePanel===0&&!state(587).companyVisible&&state(588).companyVisible&&state(601).highlight<1&&state(610).highlight===1&&state(625).databaseOpacity===0&&state(626).databaseOpacity===1);
check('品牌三行间隔两帧、分别移动与显现',state(278).brand[0].opacity>0&&state(278).brand[1].opacity===0&&state(280).brand[1].opacity>0&&state(280).brand[2].opacity===0&&state(282).brand[2].opacity>0&&state(283).brand[0].x<state(283).brand[1].x&&state(283).brand[1].x<state(283).brand[2].x);
check('品牌先加速后减速,并延续至第 305 帧',state(280).brand[0].x-state(281).brand[0].x>state(278).brand[0].x-state(279).brand[0].x&&state(291).brand[2].x>state(301).brand[2].x&&state(305).brand.every(line=>line.x===0));
check('遮罩快速扫过后长尾收束,灰闪帧仍保留层',state(592).highlight>.5&&state(600).highlight<.95&&state(594).companyVisible&&state(594).companyMask);
check('内环与橙点分别减速,白环独立收拢',state(505).scan.innerStart-state(500).scan.innerStart>state(540).scan.innerStart-state(535).scan.innerStart&&Math.abs(state(505).scan.orbit-state(510).scan.orbit)>Math.abs(state(535).scan.orbit-state(540).scan.orbit)&&state(500).scan.whiteRadius/state(500).scanRadius<state(530).scan.whiteRadius/state(530).scanRadius);
check('两侧小弧提前出现,逐渐补全到近完整圆',!state(543).scanOrbit.sideVisible&&state(544).scanOrbit.sideVisible&&state(544).scanOrbit.sides[0].sweep<.1&&state(551).scanOrbit.sides[0].sweep>Math.PI&&state(568).scanOrbit.sides.every(side=>side.sweep>5.7&&side.sweep<Math.PI*2));
check('两侧小弧反向自转,后段角速度降低',state(552).scanOrbit.sides[0].start>state(551).scanOrbit.sides[0].start&&state(552).scanOrbit.sides[1].start<state(551).scanOrbit.sides[1].start&&state(552).scanOrbit.sides[0].start-state(551).scanOrbit.sides[0].start>state(568).scanOrbit.sides[0].start-state(567).scanOrbit.sides[0].start);
check('大圆点闪变后缩小并缓慢稳定,文字渐退至 551 帧',state(546).coreRadius>42&&state(548).coreRadius<9&&state(549).coreRadius>42&&state(551).coreRadius<11&&state(560).coreRadius>11&&state(548).permissionOpacity>0&&state(551).permissionOpacity===0);
check('六颗中央小点错峰出现并沿小轨道转动',state(547).scanOrbit.satellites.every(dot=>dot.radius===0)&&state(551).scanOrbit.satellites.filter(dot=>dot.radius>0).length===2&&state(568).scanOrbit.satellites.every(dot=>dot.radius>3)&&state(551).scanOrbit.satellites[0].x<state(568).scanOrbit.satellites[0].x);
check('Logo 绘制首尾均移动,后段变为移动缺口',state(236).logo.start>state(235).logo.start&&state(236).logo.length>state(235).logo.length&&state(264).logo.length>.97&&state(460).logo.start>state(450).logo.start&&state(480).logo.start>state(470).logo.start);
check('加号与方点同帧从内侧展开,方点再拉成横线',state(246).logo.symbolScale===0&&state(247).logo.symbolScale>0&&state(247).logo.plusX>state(255).logo.plusX&&state(247).logo.minusX<state(255).logo.minusX&&state(256).logo.minusWidth===15&&state(268).logo.minusWidth===46&&state(258).logo.plusAngle===-45);
check('移动缺口不使整个 Logo 闪烁',Array.from({length:258},(_,i)=>state(229+i).logoOpacity).every(value=>value===1));
check('欢迎页在第 673 帧退出并清除背景',state(672).welcomeVisible&&!state(673).welcomeVisible&&state(673).backgroundOpacity===0);
const snapshots=[];for(let f=169;f<=675;f++){const s=state(f);checkFinite(s);snapshots.push(JSON.stringify(s))}
function checkFinite(s){for(const value of Object.values(s)){if(typeof value==='number'&&!Number.isFinite(value))throw Error('时间轴出现无效数值');if(value&&typeof value==='object')checkFinite(value)}}
check('507 帧正序与倒序计算完全一致',snapshots.every((v,i)=>v===JSON.stringify(state(169+i)))&&Array.from({length:507},(_,i)=>675-i).every(f=>JSON.stringify(state(f))===snapshots[f-169]));
check('外环半径持续收拢,文字字距收紧',state(500).scanRadius>state(510).scanRadius&&state(510).scanRadius>state(540).scanRadius&&state(540).scanRadius>state(568).scanRadius&&state(500).scanTracking>state(511).scanTracking);
await wait(()=>app.contentWindow?.rhine&&app.contentDocument.querySelector('#stage')?.dataset.mode==='boot'&&!app.contentDocument.querySelector('#loading'));
const doc=app.contentDocument,win=app.contentWindow,stage=doc.querySelector('#stage');
async function seek(f){win.postMessage({type:'rhine-review-frame',time:f/25-5},location.origin);await wait(()=>stage.dataset.bootFrame===String(f)&&stage.dataset.mode==='boot')}
function visibleSnapshot(){return ['.access-text','.boot-logo','.auth-status','.scan','.welcome','.brand','.powered'].map(sel=>{const el=doc.querySelector(sel);return [sel,el.style.cssText,Number(win.getComputedStyle(el).opacity)>0?el.outerHTML:'hidden']})}
await seek(500);const first=JSON.stringify(visibleSnapshot());await seek(635);await seek(240);await seek(500);
check('实际页面跨阶段倒退后回到相同画面',first===JSON.stringify(visibleSnapshot()));
await seek(568);const orbitSnapshot=doc.querySelector('.scan').outerHTML;await seek(551);await seek(240);await seek(568);
check('小圆和六颗环绕点正反跳帧保持一致',orbitSnapshot===doc.querySelector('.scan').outerHTML&&doc.querySelectorAll('.satellite-dot').length===6);
await seek(461);const mark=doc.querySelector('.boot-logo');check('实际 Logo 使用单条连续轮廓,移动缺口时保持不透明',mark.querySelectorAll('path[pathLength]').length===1&&mark.style.opacity==='1'&&Number(mark.querySelector('path[pathLength]').style.strokeDashoffset)!==0);
await seek(594);check('灰闪帧遮罩和未扫过的文字仍然显示',doc.querySelector('.welcome-company').style.opacity==='0.65'&&doc.querySelector('.welcome-highlight span').style.opacity==='0.06');
await seek(592);check('倒退恢复白色反相文字',doc.querySelector('.welcome-highlight span').style.opacity==='1');
check('实际页面的前段不依赖 CSS 循环动画',doc.querySelector('#boot').getAnimations({subtree:true}).length===0);
await seek(240);win.rhine.archive();
check('绘制标志时跳过恢复完整角标',stage.dataset.mode==='archive'&&[...doc.querySelector('.brand').children].every(el=>el.style.opacity===''&&el.style.transform==='')&&doc.querySelector('.powered').style.clipPath==='');
await seek(635);
check('重入欢迎页仍能显示三行和标志',doc.querySelector('.welcome-database').style.opacity==='1'&&doc.querySelector('.welcome-logo').style.opacity==='1');
await seek(675);
check('前段结束后衔接原有档案阵列阶段',stage.dataset.boot==='array'&&doc.querySelector('.welcome').style.opacity==='0'&&doc.querySelector('#boot-background').style.opacity==='0');
win.rhine.archive();
result.textContent=JSON.stringify({passed:true,checks},null,2);
}catch(error){result.textContent=JSON.stringify({passed:false,error:String(error),checks},null,2)}
</script></html>
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+49
View File
@@ -0,0 +1,49 @@
<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>开场 Novecento 字体复核</title>
<style>
body{margin:24px;background:#e8e5e1;color:#161714;font:14px/1.5 system-ui}h1{font-size:21px}button{font:inherit;margin:0 8px 8px 0;padding:7px 12px;cursor:pointer}#view{width:960px;height:540px;position:relative;max-width:100%;overflow:hidden}iframe{width:1920px;height:1080px;border:0;transform-origin:0 0;transform:scale(var(--scale,.5))}pre{white-space:pre-wrap;max-width:960px}.note{color:#666}
</style>
<h1>开场中央文字 · Novecento Sans Wide</h1>
<p>访问、身份、处理与授权提示使用 Normal;欢迎画面使用 Bold,左上 RHINE LAB 使用 DemiBold。本机授权包启用真实 WOFF2,字形位置与原时间轴保持一致。</p>
<div id="controls">
<button data-frame="187">访问提示</button><button data-frame="339">身份与姓名</button><button data-frame="389">请求接收</button><button data-frame="450">开始处理</button><button data-frame="500">授权展开</button><button data-frame="540">授权收束</button><button data-frame="635">欢迎画面</button><button id="run">运行回归检查</button>
</div>
<div id="view"><iframe id="app" src="/?time=13&freeze=1&review=1" title="开场字体预览"></iframe></div>
<p class="note">预览使用原片 1920×1080 坐标。时间、字距动画和逐字音效触发由原时间轴驱动。</p>
<pre id="result">正在加载…</pre>
<script type="module">
import {bootMotion} from '/src/boot-motion.ts';
const app=document.querySelector('#app'),view=document.querySelector('#view'),result=document.querySelector('#result');
const wait=async fn=>{const start=performance.now();while(!fn()){if(performance.now()-start>30000)throw Error('等待预览超时');await new Promise(r=>setTimeout(r,20))}};
const fit=()=>{const scale=Math.min(1,view.clientWidth/1920);app.style.setProperty('--scale',scale);view.style.height=`${1080*scale}px`};new ResizeObserver(fit).observe(view);fit();
async function ready(){await wait(()=>app.contentWindow?.rhine?.stats().ready)}
async function seek(f){await ready();app.contentWindow.postMessage({type:'rhine-review-frame',time:f/25-5},location.origin);await wait(()=>app.contentDocument.querySelector('#stage').dataset.bootFrame===String(f)&&app.contentDocument.querySelector('#stage').dataset.mode==='boot')}
document.querySelectorAll('[data-frame]').forEach(button=>button.onclick=()=>seek(Number(button.dataset.frame)));
let running=false;
async function run(){if(running)return;running=true;const checks=[];const check=(name,pass)=>{checks.push({name,pass});if(!pass)throw Error(name)};try{
await ready();const doc=app.contentDocument,win=app.contentWindow;
const brand=doc.querySelector('.brand h1');const brandArt=brand.querySelector('.boot-phrase[data-phrase="brand"]');
check('左上 RHINE LAB 使用 DemiBold,保留标题文本与三行结构',brand.textContent==='RHINE LAB'&&brandArt?.dataset.weight==='DemiBold'&&!brandArt.hidden&&doc.querySelector('.brand').children.length===3);
check('品牌第一行固定 48px 高,未推动下方两行',win.getComputedStyle(brand).height==='48px');
const hosts=['.access-text','#auth-message'];
const count=sel=>doc.querySelector(sel).querySelectorAll('.boot-phrase:not([hidden]) .boot-phrase-letter:not([hidden])').length;
// Cover every authored text change, including blank cuts, punctuation and glitches.
let previous='';let tested=0;
for(let f=169;f<=487;f++){const s=bootMotion(f/25-5),key=s.access+'|'+s.auth;if(key===previous)continue;previous=key;await seek(f);for(const [sel,text] of [[hosts[0],s.access],[hosts[1],s.auth]]){if(doc.querySelector(sel).textContent!==text||count(sel)!==text.length||doc.querySelector(sel).classList.contains('boot-lettering-fallback'))throw Error(`逐字不一致 ${f} ${sel}`)}tested++}
check(`${tested} 个实际文字变化完整覆盖,包含标点、留白及故障帧`,true);
await seek(450);const first=doc.querySelector('#auth-message .boot-phrase:not([hidden])').outerHTML;const glyph=doc.querySelector('#auth-message .boot-phrase-letter');let mutations=0;const observer=new MutationObserver(records=>mutations+=records.length);observer.observe(doc.querySelector('#auth-message'),{attributes:true,childList:true,subtree:true,characterData:true});await new Promise(r=>setTimeout(r,450));observer.disconnect();check('固定文字停留时不重建字形、不修改 DOM',mutations===0);
await seek(635);await seek(300);await seek(450);check('前后跳帧保持同一字形节点并恢复相同内容',glyph===doc.querySelector('#auth-message .boot-phrase-letter')&&first===doc.querySelector('#auth-message .boot-phrase:not([hidden])').outerHTML);
await seek(500);const span=doc.querySelector('.scan > span'),width1=span.querySelector('.boot-phrase').getBoundingClientRect().width;const ring=doc.querySelector('.scan > svg').outerHTML;
await seek(540);const width2=span.querySelector('.boot-phrase').getBoundingClientRect().width;check('授权文字实际图形随原字距收束',width1>width2+100);check('原扫描圆环仍只有六条动画弧线',doc.querySelectorAll('.scan > svg path').length===6);
await seek(500);check('扫描圆环跳帧恢复一致,文字路径未混入圆环',ring===doc.querySelector('.scan > svg').outerHTML);
await seek(594);check('欢迎白字遮罩保留灰闪帧',doc.querySelector('.welcome-highlight > span').style.opacity==='0.06');await seek(592);check('反向跳帧恢复欢迎文字',doc.querySelector('.welcome-highlight > span').style.opacity==='1');
await seek(635);check('欢迎三行全部使用 Bold 图形',doc.querySelectorAll('.welcome .boot-phrase[data-weight="Bold"]:not([hidden])').length===4);check('全场不存在未生成文案的后备显示',doc.querySelectorAll('.boot-lettering-fallback').length===0);
const native=doc.querySelector('#auth-message').dataset.letteringRenderer==='webfont';
if(native){
check('已加载三种官方 WOFF2,所有文字源已切换为真实字体',win.performance.getEntriesByType('resource').filter(r=>/novecento\/.*\.woff2/i.test(r.name)).length===3&&doc.querySelectorAll('[data-lettering-renderer="artwork"]').length===0);
check('真实字体模式不保留文字 SVG 路径',doc.querySelectorAll('.has-boot-lettering svg').length===0);
}else check('无本地授权包时保留固定图形',doc.querySelectorAll('[data-lettering-renderer="webfont"]').length===0&&doc.querySelectorAll('.has-boot-lettering svg').length>0);
await seek(675);check('开场结束正常隐藏文字并进入阵列',doc.querySelector('.welcome').style.opacity==='0'&&doc.querySelector('#stage').dataset.boot==='array');
await seek(450);result.textContent=JSON.stringify({passed:true,checks},null,2);
}catch(error){result.textContent=JSON.stringify({passed:false,error:String(error),checks},null,2)}finally{running=false}}
document.querySelector('#run').onclick=run;run();
</script></html>
+45
View File
@@ -0,0 +1,45 @@
<!doctype html>
<html lang="zh-CN">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Rhine Lab · 前段 2D 动效对照</title>
<style>
*{box-sizing:border-box}body{margin:0;background:#eae5e1;color:#262824;font:14px Arial,"Microsoft YaHei",sans-serif}main{max-width:1800px;margin:auto;padding:28px}h1{font-size:22px;margin:0 0 10px;font-weight:600}p{line-height:1.7;color:#64645c;margin:0 0 22px}.views{display:grid;grid-template-columns:1fr 1fr;gap:12px}figure{margin:0}figcaption{margin:10px 0;font-size:12px}video,iframe{display:block;width:100%;aspect-ratio:16/9;border:0;background:#eae5e1}nav{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:22px 0 16px}button,a{font:inherit;color:inherit;border:1px solid #bcb9af;background:transparent;padding:9px 14px;cursor:pointer;text-decoration:none}button:hover,a:hover{background:#dcd6cc}button:focus-visible,input:focus-visible,a:focus-visible{outline:2px solid #94714b;outline-offset:3px}input{width:100%;accent-color:#786047}output{font:14px monospace;margin-left:auto}.marks{display:flex;gap:8px;flex-wrap:wrap;margin-top:14px}small{display:block;margin-top:20px;color:#64645c;line-height:1.8}@media(max-width:850px){main{padding:16px}}
.reference-frame{position:relative}.reference-frame img{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;z-index:1;pointer-events:none}.reference-frame.playing img{display:none}
.crop{overflow:hidden;aspect-ratio:16/9}.crop>*{transform:scale(var(--zoom,1));transform-origin:var(--origin,50% 50%)}body[data-focus="ring"]{--zoom:1.7;--origin:50% 50%}body[data-focus="brand"]{--zoom:2.6;--origin:0% 0%}body[data-focus="company"]{--zoom:2.7;--origin:50% 46%}body[data-focus="orbit"]{--zoom:3.8;--origin:50% 50%}body[data-focus="logo"]{--zoom:3.8;--origin:50% 50%}body[data-focus="gap"]{--zoom:3.8;--origin:29% 50%}</style>
<main>
<h1>前段 2D 动效对照</h1>
<p>左侧为原片,右侧为实时界面。拖动时间轴或逐帧前后移动,两侧会同步;原片每秒 25 帧。</p>
<div class="views">
<figure><figcaption>原视频 · 6.76–27.00 秒</figcaption><div class="crop"><div class="reference-frame"><img id="reference-still" alt="原视频当前帧" src="boot-frames/0169.jpg"><video id="reference" muted playsinline preload="auto" src="../《明日方舟》特别映像%20[莱茵生命:访问]%20[BV1rr4y1b7sz].mp4"></video></div></div></figure>
<figure><figcaption>实时界面 · 逐帧重建</figcaption><div class="crop"><iframe id="replica" title="实时场景对照" src="/?time=1.76&freeze=1&review=1"></iframe></div></figure>
</div>
<nav><button id="play">播放对照</button><button id="prev">← 前一帧</button><button id="next">后一帧 →</button><a href="/" target="_blank">播放完整尺寸动画 ↗</a><a href="/?scene=archive" target="_blank">进入交互 ↗</a><output id="stamp">6.76 s · FRAME 169</output></nav>
<input id="timeline" aria-label="原视频帧位置" type="range" min="169" max="675" step="1" value="169">
<nav aria-label="局部放大"><label>放大位置 <select id="focus"><option value="full">完整画面</option><option value="logo">Logo 绘制与符号 · 3.8×</option><option value="gap">Logo 移动缺口 · 3.8×</option><option value="brand">左上三行文字 · 2.6×</option><option value="ring">权限圆环 · 1.7×</option><option value="orbit">小圆与环绕点 · 3.8×</option><option value="company">名称黑底扫过 · 2.7×</option></select></label><button data-frame="237">9.48 · 连续笔画</button><button data-frame="248">9.92 · 符号展开</button><button data-frame="258">10.32 · 加号旋转</button><button data-frame="460">18.40 · 缺口移动</button><button data-frame="278">11.12 · 第一行</button><button data-frame="280">11.20 · 第二行</button><button data-frame="282">11.28 · 第三行</button><button data-frame="305">12.20 · 三行落定</button><button data-frame="551">22.04 · 环绕点出现</button><button data-frame="568">22.72 · 小圆收束</button><button data-frame="592">23.68 · 黑底快速段</button><button data-frame="610">24.40 · 黑底落定</button></nav>
<div class="marks"><button data-frame="187">7.48 · 英文输入</button><button data-frame="240">9.60 · 标志绘制</button><button data-frame="286">11.44 · 身份输入</button><button data-frame="337">13.48 · 姓名</button><button data-frame="500">20.00 · 圆环收拢</button><button data-frame="540">21.60 · 圆环落定</button><button data-frame="550">22.00 · 中心图形</button><button data-frame="569">22.76 · 黑白闪切</button><button data-frame="594">23.76 · 黑底扫过</button><button data-frame="635">25.40 · 欢迎页</button><button data-frame="669">26.76 · 白场转场</button><label>速度 <select id="speed"><option value="1">正常</option><option value="0.5">半速</option><option value="0.25">四分之一</option></select></label></div>
<small>仅用于本地复核。产品从原片画面变白后开始,不包含黑底鹰角片头;原视频不进入产品页面。← / → 逐帧前后移动。</small>
</main>
<script type="module">
const video=document.querySelector('#reference'),frame=document.querySelector('#replica'),slider=document.querySelector('#timeline'),stamp=document.querySelector('#stamp'),play=document.querySelector('#play');
const still=document.querySelector('#reference-still'),params=new URLSearchParams(location.search);let desired=Math.max(169,Math.min(675,Number(params.get('frame'))||169))/25;
if(['full','logo','gap','brand','ring','orbit','company'].includes(params.get('focus'))){document.body.dataset.focus=params.get('focus');document.querySelector('#focus').value=params.get('focus')}
function sync(t){desired=t;still.src=`boot-frames/${String(Math.round(t*25)).padStart(4,'0')}.jpg`;slider.value=String(Math.round(t*25));stamp.textContent=`${t.toFixed(2)} s · FRAME ${Math.round(t*25)}`;frame.contentWindow.postMessage({type:'rhine-review-frame',time:t-5},location.origin)}
function pause(){video.pause();video.parentElement.classList.remove('playing');play.textContent='播放对照'}
function seek(n){pause();const t=Math.max(169,Math.min(675,n))/25;desired=t;sync(t);video.currentTime=t}
video.addEventListener('loadedmetadata',()=>seek(Math.round(desired*25)));
frame.addEventListener('load',()=>sync(desired));
slider.addEventListener('input',()=>seek(Number(slider.value)));
document.querySelector('#prev').onclick=()=>seek(Number(slider.value)-1);
document.querySelector('#next').onclick=()=>seek(Number(slider.value)+1);
document.querySelectorAll('[data-frame]').forEach(b=>b.onclick=()=>seek(Number(b.dataset.frame)));
play.onclick=async()=>{if(!video.paused){pause();return}if(video.currentTime>=27.0)seek(169);await video.play();video.parentElement.classList.add('playing');play.textContent='暂停'};
video.addEventListener('seeked',()=>sync(video.currentTime));
function tick(_,metadata){if(!video.paused){if(metadata.mediaTime>=27.04){seek(169)}else sync(metadata.mediaTime)}video.requestVideoFrameCallback(tick)}
video.requestVideoFrameCallback(tick);
document.querySelector('#speed').onchange=e=>video.playbackRate=Number(e.target.value);
document.querySelector('#focus').onchange=e=>{document.body.dataset.focus=e.target.value;seek({logo:229,gap:425,brand:278,ring:532,orbit:544,company:592,full:Number(slider.value)}[e.target.value])};
document.addEventListener('keydown',e=>{if(e.target===slider)return;if(e.code==='ArrowLeft'){e.preventDefault();seek(Number(slider.value)-1)}if(e.code==='ArrowRight'){e.preventDefault();seek(Number(slider.value)+1)}});
</script>
</html>
+106
View File
@@ -0,0 +1,106 @@
<!doctype html>
<html lang="zh-CN">
<meta charset="utf-8" /><title>品牌文字放大对照</title
><link rel="stylesheet" href="/src/style.css" /><style>
body {
overflow: auto;
padding: 20px;
}
main {
max-width: 1000px;
}
h2 {
font: 16px system-ui;
margin: 20px 0 8px;
}
.crop {
position: relative;
width: 732px;
height: 308px;
overflow: hidden;
background: #e4e2dd;
}
.crop img {
width: 732px;
height: 308px;
}
.crop .brand {
left: 33.6px;
top: 21.6px;
transform: scale(2.4);
transform-origin: 0 0;
}
.guides {
position: absolute;
left: 34px;
top: 0;
bottom: 0;
width: 646px;
border-inline: 1px dashed #bc765b;
pointer-events: none;
}
.overlay .brand {
color: #c63d30;
opacity: 0.55;
}
pre {
font: 14px monospace;
white-space: pre-wrap;
}
</style>
<main>
<h2>原片左上角 · 2.4 倍放大</h2>
<div class="crop">
<img src="brand-original.jpg" alt="原片品牌文字放大" />
<div class="guides"></div>
</div>
<h2>当前界面 · 同倍率</h2>
<div class="crop">
<header class="brand">
<h1>RHINE LAB</h1>
<div>SYNTHESIZE INFORMATION</div>
<p>ANALYSIS <b>OS</b></p>
</header>
<div class="guides"></div>
</div>
<h2 id="overlay">原片与当前叠加 · 当前文字用红色标出</h2>
<label style="display: block; margin: 12px 0; font-size: 14px"
>当前文字透明度
<input id="opacity" type="range" min="0" max="1" step="0.05" value="0.55"
/></label>
<div class="crop overlay">
<img src="brand-original.jpg" alt="原片与当前文字叠加" />
<header class="brand">
<h1>RHINE LAB</h1>
<div>SYNTHESIZE INFORMATION</div>
<p>ANALYSIS <b>OS</b></p>
</header>
<div class="guides"></div>
</div>
<pre id="metrics"></pre>
</main>
<script type="module">
import { brandHeading } from "/src/brand.ts";
document
.querySelectorAll(".brand")
.forEach((el) => (el.innerHTML = brandHeading));
await document.fonts.ready;
document.querySelector("#metrics").textContent = JSON.stringify(
[...document.querySelector(".brand").children].map((el) => {
const r = document.createRange();
r.selectNodeContents(el);
return {
line: el.textContent,
width: r.getBoundingClientRect().width / 2.4,
spacing: getComputedStyle(el).letterSpacing,
fontSize: getComputedStyle(el).fontSize,
};
}),
null,
2,
);
document.querySelector("#opacity").oninput = (e) =>
(document.querySelector(".overlay .brand").style.opacity =
e.target.value);
</script>
</html>
Binary file not shown.
+14
View File
@@ -0,0 +1,14 @@
"""Compare matching source/DOM crops after capturing the review page."""
from pathlib import Path
from PIL import Image,ImageDraw
out=Path(__file__).resolve().parent/'boot-frames'
for name,frames in [('logo',[237,248,258,460,474,479]),('orbits',[546,551,556,560,564,568])]:
sheet=Image.new('RGB',(1520,len(frames)*464),'#242424');d=ImageDraw.Draw(sheet)
for i,f in enumerate(frames):
crop=(780,440,1140,660) if f<265 else (500,440,840,660) if f<500 else (770,430,1150,650)
a=Image.open(out/f'{f:04}.jpg').crop(crop).resize((760,440))
b=Image.open(out/f'detail-replica-{f}.jpg').resize((760,440))
y=i*464;d.text((8,y+5),f'{f} / {f/25:.2f}s reference / replica',fill='white')
sheet.paste(a,(0,y+24));sheet.paste(b,(760,y+24))
pair=Image.new('RGB',(1520,440));pair.paste(a,(0,0));pair.paste(b,(760,0));pair.save(out/f'pair-{f}.jpg',quality=96)
sheet.save(out/f'{name}-comparison.jpg',quality=96)
+10
View File
@@ -0,0 +1,10 @@
<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>阵列覆盖与裁剪验证</title><style>body{margin:0;background:#939896;font:13px system-ui}header{padding:8px;display:flex;gap:6px;flex-wrap:wrap}button{padding:7px}#view{position:relative;margin:auto;overflow:hidden}iframe{border:0;transform-origin:top left;display:block}output{display:block;white-space:pre-wrap;padding:8px}#error{color:#800}</style><header><button data-size="1920,1080">16:9</button><button data-size="2560,1080">21:9</button><button data-size="3840,1080">32:9</button><button data-size="1400,1050">4:3</button><button data-size="1080,1920">竖屏</button><button data-theme="light">亮色</button><button data-theme="dark">暗色</button><button data-coverage="standard">标准余量</button><button data-coverage="extra">额外余量</button><button id="detail">详情</button><button id="archive">阵列</button><button id="navigate">连续换档</button></header><div id="view"><iframe src="/?scene=archive" title="自适应档案阵列"></iframe></div><output></output><output id="error"></output><script>
const f=document.querySelector('iframe'),view=document.querySelector('#view');let size=[1920,1080];
function fit(){const scale=Math.min((innerWidth-20)/size[0],(innerHeight-165)/size[1]);f.style.width=size[0]+'px';f.style.height=size[1]+'px';f.style.transform=`scale(${scale})`;view.style.width=size[0]*scale+'px';view.style.height=size[1]*scale+'px'}fit();window.onresize=fit;
const props=p=>f.contentWindow.wallpaperPropertyListener?.applyUserProperties(Object.fromEntries(Object.entries(p).map(([k,v])=>[k,{value:v}])));
f.onload=()=>{props({desktopmode:'workbench',reduced:true,sound:false,music:false,audioreactive:false,colortheme:'dark',selectionstyle:'flat'});f.contentWindow.addEventListener('error',e=>document.querySelector('#error').textContent=e.message)};
document.querySelectorAll('[data-size]').forEach(b=>b.onclick=()=>{size=b.dataset.size.split(',').map(Number);fit()});document.querySelectorAll('[data-theme]').forEach(b=>b.onclick=()=>props({colortheme:b.dataset.theme}));document.querySelectorAll('[data-coverage]').forEach(b=>b.onclick=()=>props({archivecoverage:b.dataset.coverage}));
document.querySelector('#detail').onclick=()=>{props({desktopmode:'archive'});f.contentWindow.rhine.detail()};document.querySelector('#archive').onclick=()=>{f.contentWindow.rhine.archive();props({desktopmode:'workbench'})};
document.querySelector('#navigate').onclick=()=>{props({desktopmode:'archive',reduced:false});let n=0;const interval=setInterval(()=>{f.contentWindow.rhine.select((n++*7)%40);if(n===12)clearInterval(interval)},120)};
setInterval(()=>{const s=f.contentWindow.rhine?.stats();if(s)document.querySelector('output').textContent=JSON.stringify({size,drawn:s.archiveCount,candidates:s.archiveCandidates,capacity:s.archiveCapacity,coverage:s.archiveCoverage,triangles:s.triangles,calls:s.drawCalls,mode:s.mode,fps:s.fps,window:s.loopWindow},null,1)},500);
</script></html>
+37
View File
@@ -0,0 +1,37 @@
<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>解密与玻璃模式检查</title>
<style>body{background:#e8e5e1;font:14px system-ui;padding:20px}iframe{width:720px;height:405px;border:0}pre{white-space:pre-wrap}</style>
<h1>解密与玻璃模式检查</h1><pre id="result">正在检查…</pre><iframe id="app" src="/?scene=detail"></iframe>
<script type="module">
const app=document.querySelector('#app'),result=document.querySelector('#result'),checks=[];
const check=(name,value)=>{checks.push({name,passed:!!value});if(!value)throw Error(name)};
const wait=async test=>{const start=performance.now();while(!test()){if(performance.now()-start>45000)throw Error('等待超时');await new Promise(r=>setTimeout(r,30))}};
try{
await wait(()=>app.contentWindow.rhine?.stats().loaded);
const doc=app.contentDocument,api=app.contentWindow.rhine;
await wait(()=>api.stats().decryption.phase==='joining');
const path=doc.querySelector('#inspection-lines').getAttribute('d');
const n=path.match(/-?\d+(?:\.\d+)?(?:e[+-]?\d+)?/gi).map(Number);
const cross=(ax,ay,bx,by)=>ax*by-ay*bx;
check('投影后的两段线严格共线',n.length===8&&Math.abs(cross(n[2]-n[0],n[3]-n[1],n[6]-n[0],n[7]-n[1]))<.01);
await wait(()=>api.stats().decryption.phase==='clear');
check('详情解密结束后变清晰且线消失',api.stats().decryption.clarity===1&&doc.querySelector('#inspection-marks').style.opacity==='0');
check('长焦详情使用足够精度的近裁剪面',api.stats().cameraNear===5);
doc.querySelector('.viewer-open').click();const viewer=doc.querySelector('.model-viewer');
const stats=()=>JSON.parse(viewer.dataset.stats||'{}');await wait(()=>stats().ready);
const original=stats();viewer.querySelector('[data-viewer="frosted"]').click();
await wait(()=>stats().clarity>0&&stats().clarity<.9);check('磨砂有连续过渡',stats().targetClarity===0);
viewer.querySelector('[data-viewer="clear"]').click();await wait(()=>stats().clarity===1);
check('快速反向切换回清晰保留镜头',Math.abs(stats().distance-original.distance)<1e-8&&stats().spread===0);
viewer.querySelector('[data-viewer="explode"]').click();await wait(()=>stats().spread>.98);
viewer.querySelector('[data-viewer="frosted"]').click();await wait(()=>stats().clarity===0);
check('拆解后仍可切换玻璃模式',stats().spread>.98&&stats().targetClarity===0);
viewer.querySelector('[data-viewer="assemble"]').click();await wait(()=>stats().spread<.001);
viewer.querySelector('[data-viewer="close"]').click();await wait(()=>viewer.hidden);
check('退出查看器后原档案保持清晰',api.stats().decryption.clarity===1);
api.archive();await wait(()=>api.stats().decryption.clarity===0);api.detail();
await wait(()=>api.stats().decryption.phase==='joining');api.archive();
await wait(()=>doc.querySelector('#inspection-marks').style.opacity==='0');
check('解密中退出及时清除标记',api.stats().decryption.intervals.length===0);
result.textContent=JSON.stringify({passed:true,checks},null,2);
}catch(error){result.textContent=JSON.stringify({passed:false,error:String(error),checks},null,2)}
</script></html>
+40
View File
@@ -0,0 +1,40 @@
<!doctype html>
<html lang="zh-CN">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Rhine Lab · 解密线与透明过渡对照</title>
<style>
*{box-sizing:border-box}body{margin:0;background:#eae5e1;color:#262824;font:14px Arial,"Microsoft YaHei",sans-serif}main{max-width:1800px;margin:auto;padding:28px}h1{font-size:22px;margin:0 0 10px;font-weight:600}p{line-height:1.7;color:#64645c;margin:0 0 22px}.views{display:grid;grid-template-columns:1fr 1fr;gap:12px}figure{margin:0}figcaption{margin:10px 0;font-size:12px}video,iframe{display:block;width:100%;aspect-ratio:16/9;border:0;background:#eae5e1}nav{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:22px 0 16px}button,a{font:inherit;color:inherit;border:1px solid #bcb9af;background:transparent;padding:9px 14px;cursor:pointer;text-decoration:none}button:hover,a:hover{background:#dcd6cc}button:focus-visible,input:focus-visible,a:focus-visible{outline:2px solid #94714b;outline-offset:3px}input{width:100%;accent-color:#786047}output{font:14px monospace;margin-left:auto}.marks{display:flex;gap:8px;flex-wrap:wrap;margin-top:14px}small{display:block;margin-top:20px;color:#64645c;line-height:1.8}@media(max-width:850px){.views{grid-template-columns:1fr}main{padding:16px}}
.reference-frame{position:relative}.reference-frame img{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;z-index:1;pointer-events:none}.reference-frame.playing img{display:none}</style>
<main>
<h1>解密线与透明过渡对照</h1>
<p>左侧为原片,右侧为实时三维场景。拖动时间轴或逐帧前后移动,两侧会同步;原片每秒 25 帧。</p>
<div class="views">
<figure><figcaption>原视频 · 34.00–39.96 秒</figcaption><div class="reference-frame"><img id="reference-still" alt="原视频当前帧" src="motion/0787.jpg"><video id="reference" muted playsinline preload="auto" src="../《明日方舟》特别映像%20[莱茵生命:访问]%20[BV1rr4y1b7sz].mp4"></video></div></figure>
<figure><figcaption>实时场景 · Blender 模型 / Three.js</figcaption><iframe id="replica" title="实时场景对照" src="/?time=29&freeze=1&review=1"></iframe></figure>
</div>
<nav><button id="play">播放对照</button><button id="prev">← 前一帧</button><button id="next">后一帧 →</button><a href="/?time=21.8" target="_blank">播放完整尺寸动画 ↗</a><a href="/?scene=archive" target="_blank">进入交互 ↗</a><output id="stamp">34.00 s · FRAME 850</output></nav>
<input id="timeline" aria-label="原视频帧位置" type="range" min="850" max="999" step="1" value="850">
<div class="marks"><button data-frame="860">34.40 · 两端出现</button><button data-frame="866">34.64 · 快速延伸</button><button data-frame="901">36.04 · 连成一线</button><button data-frame="943">37.72 · 开始收束</button><button data-frame="950">38.00 · 收回中央</button><button data-frame="971">38.84 · 开始清晰</button><button data-frame="975">39.00 · 上部清晰</button><button data-frame="984">39.36 · 逐渐透明</button><button data-frame="990">39.60 · 清晰</button><label>速度 <select id="rate"><option value="1">1×</option><option value="0.5">0.5×</option><option value="0.25">0.25×</option></select></label></div>
<small>此页仅用于本地复核,产品入口没有播放或嵌入参考视频。两侧仍存在几何、光照与景深差异;时间轴为逐帧观察后的运动重建,没有从原片恢复相机或三维场景数据。</small>
</main>
<script type="module">
const video=document.querySelector('#reference'),frame=document.querySelector('#replica'),slider=document.querySelector('#timeline'),stamp=document.querySelector('#stamp'),play=document.querySelector('#play');
document.querySelector('#rate').onchange=e=>video.playbackRate=Number(e.target.value);
const still=document.querySelector('#reference-still');let desired=34.0;
function sync(t){desired=t;still.src=`motion/${String(Math.round(t*25)).padStart(4,'0')}.jpg`;slider.value=String(Math.round(t*25));stamp.textContent=`${t.toFixed(2)} s · FRAME ${Math.round(t*25)}`;frame.contentWindow.postMessage({type:'rhine-review-frame',time:t-5},location.origin)}
function pause(){video.pause();video.parentElement.classList.remove('playing');play.textContent='播放对照'}
function seek(n){pause();const t=Math.max(850,Math.min(999,n))/25;desired=t;video.currentTime=t}
video.addEventListener('loadedmetadata',()=>seek(Math.round(desired*25)));
frame.addEventListener('load',()=>sync(desired));
slider.addEventListener('input',()=>seek(Number(slider.value)));
document.querySelector('#prev').onclick=()=>seek(Number(slider.value)-1);
document.querySelector('#next').onclick=()=>seek(Number(slider.value)+1);
document.querySelectorAll('[data-frame]').forEach(b=>b.onclick=()=>seek(Number(b.dataset.frame)));
play.onclick=async()=>{if(!video.paused){pause();return}if(video.currentTime>=39.92)seek(850);await video.play();video.parentElement.classList.add('playing');play.textContent='暂停'};
video.addEventListener('seeked',()=>sync(video.currentTime));
function tick(_,metadata){if(!video.paused){if(metadata.mediaTime>=39.96){seek(850)}else sync(metadata.mediaTime)}video.requestVideoFrameCallback(tick)}
video.requestVideoFrameCallback(tick);
document.addEventListener('keydown',e=>{if(e.target===slider)return;if(e.code==='ArrowLeft'){e.preventDefault();seek(Number(slider.value)-1)}if(e.code==='ArrowRight'){e.preventDefault();seek(Number(slider.value)+1)}});
</script>
</html>
+35
View File
@@ -0,0 +1,35 @@
<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>相机深度范围检查</title>
<style>body{font:14px system-ui;background:#e8e5e1;padding:20px}#scene{width:960px;height:540px}pre{white-space:pre-wrap}</style>
<h1>相机深度范围检查</h1><pre id="result">正在检查…</pre><div id="scene"></div>
<script type="module">
import * as THREE from '/node_modules/three/build/three.module.js';
import {ArchiveScene} from '/src/scene.ts';
const s=new ArchiveScene(document.querySelector('#scene'));
const draw=s.composer.render.bind(s.composer);s.composer.render=()=>{};
const result=document.querySelector('#result'),samples=[];
const ease=t=>{t=Math.max(0,Math.min(1,t));return t*t*t*(t*(6*t-15)+10)};
try{
await s.load();s.setMode('hidden');s.select(0);
for(const time of [27,27.2,27.6,28,29,30,31,32,34,36,39.6]){
const shot=time-5,zoom=.55*ease((shot-27.3)/1.65)+.45*ease((shot-29)/5);
s.update(time,{reveal:1,lift:0,zoom,time:shot});s.scene.updateMatrixWorld(true);
const camera=new THREE.PerspectiveCamera().copy(s.camera);camera.near=.1;camera.updateProjectionMatrix();camera.updateMatrixWorld(true);
const frustum=new THREE.Frustum().setFromProjectionMatrix(new THREE.Matrix4().multiplyMatrices(camera.projectionMatrix,camera.matrixWorldInverse));
let nearest=Infinity,visible=0;const matrix=new THREE.Matrix4(),world=new THREE.Matrix4(),box=new THREE.Box3();
s.scene.traverse(mesh=>{
if(!mesh.isMesh||!mesh.visible||mesh.geometry.type==='PlaneGeometry')return;
mesh.geometry.computeBoundingBox();
for(let i=0;i<(mesh.isInstancedMesh?mesh.count:1);i++){
if(mesh.isInstancedMesh){mesh.getMatrixAt(i,matrix);world.multiplyMatrices(mesh.matrixWorld,matrix)}else world.copy(mesh.matrixWorld);
box.copy(mesh.geometry.boundingBox).applyMatrix4(world);if(!frustum.intersectsBox(box))continue;
visible++;
for(const x of [box.min.x,box.max.x])for(const y of [box.min.y,box.max.y])for(const z of [box.min.z,box.max.z]){
const p=new THREE.Vector3(x,y,z).applyMatrix4(camera.matrixWorldInverse);nearest=Math.min(nearest,-p.z);
}
}
});
samples.push({time,visible,nearest,near:s.camera.near});if(nearest<=s.camera.near)throw Error('可见模型接近裁剪面');
}
draw();result.textContent=JSON.stringify({passed:true,samples},null,2);
}catch(e){result.textContent=JSON.stringify({passed:false,error:String(e),samples},null,2)}
</script></html>
+81
View File
@@ -0,0 +1,81 @@
<!doctype html>
<html lang="zh-CN">
<meta charset="utf-8">
<title>详情切换雾效回归检查</title>
<style>body{margin:0;background:#e8e5e1;color:#252620;font:14px monospace}nav{padding:12px}a{color:inherit;margin-right:18px}#scene{width:960px;height:540px}pre{white-space:pre-wrap;padding:12px}</style>
<nav><a href="?t=0.6">进入 0.6s</a><a href="?t=1">进入 1s</a><a href="?t=1.5">进入 1.5s</a><a href="?t=3">进入 3s</a><a href="?exit=1&t=1">退出 1s</a></nav>
<div id="scene"></div><pre id="result">运行检查…</pre>
<script type="module">
import * as THREE from 'three';
import { ArchiveScene } from '/src/scene.ts';
const params=new URLSearchParams(location.search);
const scene=new ArchiveScene(document.querySelector('#scene'));
if(params.has('near'))scene.camera.near=Number(params.get('near'));
await scene.load();
scene.select(24);
// Run the real scene update at deterministic times; only draw the final frame.
const render=scene.composer.render.bind(scene.composer);
scene.composer.render=()=>{};
let time=0;
const step=()=>scene.update(time+=1/60);
scene.setMode('archive');
for(let i=0;i<300;i++)step();
if(params.has('reduced'))scene.setReduced(true);
if(!params.has('idle'))scene.setMode('detail');
if(params.has('exit')){
for(let i=0;i<300;i++)step();
scene.rotation=scene.targetRotation=Number(params.get('angle')??0);
step();
scene.setMode('archive');
if(params.has('swap'))scene.select(25);
}
const samples=[];
const seconds=Number(params.get('t')??1);
for(let i=0;i<Math.round(seconds*60);i++){
step();
const depth=-scene.model.position.clone().add(new THREE.Vector3(0,1.85,0)).applyMatrix4(scene.camera.matrixWorldInverse).z;
const d=scene.detail;
const oldDistance=THREE.MathUtils.lerp(140,72,d);
samples.push({
actualFog:THREE.MathUtils.smoothstep(depth,scene.scene.fog.near,scene.scene.fog.far),
previousFog:THREE.MathUtils.smoothstep(depth,oldDistance+THREE.MathUtils.lerp(5,-1,d),oldDistance+THREE.MathUtils.lerp(25,12,d)),
rangeError:Math.abs(scene.scene.fog.near-scene.camera.position.distanceTo(scene.cameraAim)-THREE.MathUtils.lerp(5,-1,d)),
idleGain:scene.idleGain,
modelY:scene.model.position.y,
});
}
if(params.has('angle')&&!params.has('exit')){
scene.rotation=scene.targetRotation=Number(params.get('angle'));
step();
}
if(params.has('sharp')){
scene.bokeh.enabled=false;
scene.renderer.setPixelRatio(1);
scene.composer.setPixelRatio(1);
}
if(params.has('close')){
const box=document.querySelector('#scene');
box.style.width='1100px';box.style.height='800px';
scene.renderer.setSize(1100,800);
scene.composer.setSize(1100,800);
scene.camera.setViewOffset(1920,1080,0,180,1100,800);
}
if(params.has('noao'))scene.ao.enabled=false;
if(params.has('noshadow'))scene.renderer.shadowMap.enabled=false;
if(params.has('offset')){
for(const mesh of scene.model.children){
if(mesh.userData.surface==='Amber_Lightguide'){
mesh.material.polygonOffset=params.get('offset')!=='0';
mesh.material.polygonOffsetFactor=-1;
mesh.material.polygonOffsetUnits=-2;
}
}
}
render();
const maxFog=Math.max(...samples.map(s=>s.actualFog));
const rangeError=Math.max(...samples.map(s=>s.rangeError));
const passed=maxFog<0.08&&rangeError<1e-8&&(!params.has('reduced')||scene.idleGain<0.001);
document.querySelector('#result').textContent=JSON.stringify({passed,phase:params.has('idle')?'idle':params.has('exit')?'exit':'entry',seconds,maxFog,previousMaxFog:Math.max(...samples.map(s=>s.previousFog)),rangeError,idleRange:Math.max(...samples.map(s=>s.modelY))-Math.min(...samples.map(s=>s.modelY)),materialOffsets:[scene.model,...scene.outgoing.map(o=>o.group)].map(g=>{const m=g.children.find(m=>m.userData.surface==='Amber_Lightguide').material;return [m.polygonOffset,m.polygonOffsetFactor,m.polygonOffsetUnits]}),stats:scene.getStats()},null,2);
if(!passed)throw new Error('Camera-relative fog regression');
</script>
</html>
+51
View File
@@ -0,0 +1,51 @@
<!doctype html>
<html lang="zh-CN">
<meta charset="utf-8">
<title>文档同步解密 · 验证</title>
<style>body{background:#e8e5e1;color:#252820;font:14px system-ui;margin:24px}iframe{width:960px;height:540px;border:0}pre{white-space:pre-wrap}button{padding:10px 20px}</style>
<h1>文档同步解密</h1>
<button id="replay">重新体验</button>
<pre id="result">正在验证遮挡、同步揭示和页签切换…</pre>
<iframe id="app" src="/?scene=detail"></iframe>
<script type="module">
const app=document.querySelector('#app'), result=document.querySelector('#result'), checks=[];
const wait=async test=>{const start=performance.now();while(!test()){if(performance.now()-start>45000)throw Error('等待超时');await new Promise(r=>setTimeout(r,16))}};
const check=(name,value)=>{checks.push({name,passed:!!value});if(!value)throw Error(name)};
const sleep=ms=>new Promise(r=>setTimeout(r,ms));
document.querySelector('#replay').onclick=async()=>{const api=app.contentWindow.rhine;api.archive();await sleep(600);api.detail()};
try{
await wait(()=>app.contentWindow.rhine?.stats().ready);
const api=app.contentWindow.rhine, doc=app.contentDocument;
const covers=()=>[...doc.querySelectorAll('.document-redaction-ink')];
await wait(()=>api.stats().decryption.phase==='joining');
check('模型合拢时标题、字段、正文完整遮挡',covers().length>8&&covers().every(n=>n.style.transform==='translateX(0%)'));
const body=doc.querySelector('#tab-panel');
const bodyHeight=body.clientHeight;
check('摘要遮挡不会撑出横向滚动',body.scrollWidth===body.clientWidth);
doc.querySelector('[data-tab="notes"]').click();
check('解密中切换页签,新内容仍受遮挡',doc.querySelectorAll('.research-notes .document-redaction-ink').length>0);
const rows=[...doc.querySelectorAll('.research-notes li')];
const positions=rows.map(n=>n.getBoundingClientRect().top);
await wait(()=>api.stats().decryption.phase==='revealing');
await wait(()=>covers().some(n=>parseFloat(n.style.transform.slice(11))>5));
check('模型清晰揭示触发文字错峰退开',covers().some(n=>n.style.transform!==covers()[0].style.transform));
check('退开时正文布局稳定',rows.every((n,i)=>Math.abs(n.getBoundingClientRect().top-positions[i])<.1));
check('揭示过程中正文无横向溢出',body.scrollWidth===body.clientWidth&&body.clientHeight===bodyHeight);
await wait(()=>covers().length===0);
check('动画结束清除所有遮挡且模型清晰',api.stats().decryption.clarity===1);
check('解密前后正文高度不变',body.clientHeight===bodyHeight);
const panel=doc.querySelector('#tab-panel');
doc.querySelector('[data-action="bookmark"]').click();doc.querySelector('[data-action="bookmark"]').click();
check('收藏保留正文节点且不重播',doc.querySelector('#tab-panel')===panel&&covers().length===0);
doc.querySelector('[data-tab="history"]').click();
check('解密后其他页签直接可读',covers().length===0&&doc.querySelector('.log-row'));
api.archive();await sleep(650);api.detail();
await wait(()=>api.stats().decryption.phase==='joining');
check('退出再进入重新遮挡',covers().length>0&&covers().every(n=>n.style.transform==='translateX(0%)'));
api.archive();await sleep(80);api.detail();
await wait(()=>api.stats().decryption.phase==='clear');await wait(()=>covers().length===0);
check('快速退出再进入正常完成',true);
result.textContent=JSON.stringify({passed:true,checks},null,2);
}catch(error){result.textContent=JSON.stringify({passed:false,error:String(error),checks},null,2)}
</script>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 481 KiB

+28
View File
@@ -0,0 +1,28 @@
import sys
from pathlib import Path
root=next(p for p in Path(__file__).resolve().parents if any(p.glob('*.mp4')))
for parent in Path(__file__).resolve().parents:
if (parent/'.tools').is_dir():sys.path.insert(0,str(parent/'.tools'))
import cv2
from PIL import Image,ImageDraw
out=Path('reference/boot-frames');out.mkdir(parents=True,exist_ok=True)
v=cv2.VideoCapture(str(next(root.glob('*.mp4'))))
print('VIDEO',v.get(3),v.get(4),v.get(5),v.get(7))
v.set(cv2.CAP_PROP_POS_FRAMES,125)
for fr in range(125,676):
ok,im=v.read()
if not ok:break
cv2.imwrite(str(out/f'{fr:04}.jpg'),im,[cv2.IMWRITE_JPEG_QUALITY,94])
sheet=Image.new('RGB',(1600,1845),'#242424');d=ImageDraw.Draw(sheet)
for i,fr in enumerate(range(125,676,12)):
im=Image.open(out/f'{fr:04}.jpg');im.thumbnail((320,180));x=i%5*320;y=i//5*200
sheet.paste(im,(x,y+20));d.text((x+8,y+3),f'{fr/25:.2f}s / {fr}',fill='white')
sheet.save(out/'overview.jpg',quality=95)
for sec in range(5,27):
sheet=Image.new('RGB',(1920,1200),'#242424');d=ImageDraw.Draw(sheet)
for i in range(25):
fr=sec*25+i;im=Image.open(out/f'{fr:04}.jpg');im.thumbnail((384,216));x=i%5*384;y=i//5*240
sheet.paste(im,(x,y+24));d.text((x+8,y+5),f'{fr/25:.2f}s / {fr}',fill='white')
sheet.save(out/f'second-{sec:02}.jpg',quality=95)
print('Extracted',551,'frames and 22 consecutive-frame sheets')
Image.open(out/'0337.jpg').crop((45,105,350,233)).resize((915,384)).save(out.parent/'brand-original.jpg',quality=97)
+15
View File
@@ -0,0 +1,15 @@
import sys
sys.path.insert(0,'.tools')
import cv2
from PIL import Image,ImageDraw
from pathlib import Path
v=cv2.VideoCapture(str(next(Path('.').glob('*.mp4'))))
print('VIDEO',v.get(3),v.get(4),v.get(5),v.get(7))
sheet=Image.new('RGB',(1280,1800),'#202020'); d=ImageDraw.Draw(sheet)
for i,t in enumerate(range(5,41,2)):
v.set(0 if False else cv2.CAP_PROP_POS_MSEC,t*1000); ok,f=v.read()
if ok:
cv2.imwrite(f'reference/{t:02}.jpg',f)
im=Image.fromarray(cv2.cvtColor(f,cv2.COLOR_BGR2RGB)); im.thumbnail((640,340)); x=(i%2)*640; y=(i//2)*200
im.thumbnail((640,180)); sheet.paste(im,(x,y+20)); d.text((x+5,y+3),str(t)+'s',fill='white')
sheet.save('reference/contact.jpg')
+12
View File
@@ -0,0 +1,12 @@
"""Enlarged consecutive-frame sheets for the drawn contour and moving cut."""
from pathlib import Path
from PIL import Image,ImageDraw
out=Path(__file__).resolve().parent/'boot-frames'
for first,last,crop in [(229,249,(795,450,1125,650)),(249,269,(795,450,1125,650)),(440,460,(505,450,825,650)),(460,480,(505,450,825,650)),(480,487,(505,450,825,650))]:
w,h=640,390;cols=4
sheet=Image.new('RGB',(cols*w,((last-first+cols-1)//cols)*(h+24)),'#242424');d=ImageDraw.Draw(sheet)
for i,f in enumerate(range(first,last)):
x=i%cols*w;y=i//cols*(h+24)
sheet.paste(Image.open(out/f'{f:04}.jpg').crop(crop).resize((w,h)),(x,y+24))
d.text((x+8,y+5),f'{f} / {f/25:.2f}s',fill='white')
sheet.save(out/f'logo-detail-{first}.jpg',quality=96)
+46
View File
@@ -0,0 +1,46 @@
<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>内部模型 · 第一版恢复核对</title>
<style>
*{box-sizing:border-box}body{margin:0;background:#eae5e1;color:#292c28;font:14px Arial,"Microsoft YaHei",sans-serif}main{max-width:1700px;margin:auto;padding:28px}h1{font-size:23px;margin:0 0 12px}p{color:#676962;line-height:1.7}nav{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin:24px 0}select,button,a{padding:9px 12px;border:1px solid #b7b9b0;background:transparent;color:inherit;font:inherit}a{text-decoration:none}label{display:flex;gap:10px;align-items:center}.views{display:grid;grid-template-columns:1fr 1fr;gap:20px}.view{height:440px;position:relative;background:#e1e0d9}canvas{width:100%!important;height:100%!important}h2{font-size:15px;font-weight:500}pre{font:12px monospace;white-space:pre-wrap;color:#666}button[aria-pressed=true]{background:#353b30;color:#f1f0e8}@media(max-width:800px){.views{grid-template-columns:1fr}.view{height:330px}}</style>
<main><h1>内部模型 · 第一版恢复核对</h1><p>右侧已恢复左侧第一版的完整内构与材质,装配模式使用修订后的外壳。两边使用相同灯光和视角。</p>
<nav><label>显示<select id="display"><option value="interior">仅内部结构</option><option value="clear">装配 · 清晰盖板</option><option value="frosted">装配 · 磨砂盖板</option></select></label><button data-angle="oblique" aria-pressed="true">斜向观察</button><button data-angle="front">正面</button><button data-angle="edge">低角度</button><label>缩放<select id="zoom"><option value="1">完整内构</option><option value="1.6">连接处 1.6×</option><option value="2.2">小环内圈 2.2×</option></select></label><a href="/?scene=detail" target="_blank">打开详情体验 ↗</a></nav>
<section class="views"><article><h2>第一版 · 原始内构</h2><div class="view" id="before"></div></article><article><h2>已恢复 · 第一版内构</h2><div class="view" id="after"></div></article></section><pre id="status">正在加载…</pre></main>
<script type="module">
import * as THREE from 'three';
import {GLTFLoader} from 'three/addons/loaders/GLTFLoader.js';
import {ArchiveScene} from '/src/scene.ts';
const $=q=>document.querySelector(q),scenes=[new ArchiveScene($('#before')),new ArchiveScene($('#after'))],groups=[];
let angle='oblique',ready=false;const grids=[];
scenes.forEach(s=>s.composer.render=()=>{});
async function asset(s,path){
const raw=(await new GLTFLoader().loadAsync(path)).scene;raw.updateMatrixWorld(true);const group=new THREE.Group();
raw.traverse(o=>{if(!o.isMesh)return;const m=new THREE.Mesh(o.geometry.clone().applyMatrix4(o.matrixWorld),o.material);m.userData={assemblyPart:o.userData.assemblyPart,surface:o.material.name.replace(/\.\d+$/,'')};group.add(m)});
s.appearance.prepare(group);s.appearance.apply(group,1);s.appearance.setClarity(group,1);group.position.y=-1.85;s.scene.add(group);return group;
}
function draw(){
if(!ready)return;const mode=$('#display').value,zoom=Number($('#zoom').value);
scenes.forEach((s,i)=>{
grids[i].visible=mode==='interior';const g=groups[i];for(const mesh of g.children)mesh.visible=mode!=='interior'||['optical-core','optical-lenses'].includes(mesh.userData.assemblyPart);
s.appearance.setClarity(g,mode==='frosted'?0:1);
const points={front:[.2,.15,7.6],oblique:[2.6,4.5,6.6],edge:[3.8,7.5,3.0]};
const target=new THREE.Vector3(zoom>2?1.125:zoom>1?.43:.12,zoom>2?.605:zoom>1?-.30:.1,0);
const dir=new THREE.Vector3(...points[angle]);s.camera.position.copy(target).add(dir.multiplyScalar((mode==='interior'?.66:1.03)/zoom));s.camera.near=.1;s.camera.far=120;s.camera.fov=34;
s.camera.aspect=s.container.clientWidth/s.container.clientHeight;s.camera.lookAt(target);s.camera.updateProjectionMatrix();s.camera.updateMatrixWorld(true);
s.renderer.setPixelRatio(Math.min(devicePixelRatio,2));s.renderer.setSize(s.container.clientWidth,s.container.clientHeight,false);
s.renderer.render(s.scene,s.camera);
});
$('#status').textContent='已载入 · 两侧内构的几何、法线及原始材质一致。右侧装配模式含已修好的外壳;网格仅用于观察层次。';
}
try{
await Promise.all(scenes.map((s,i)=>s.load(i?'/assets/archive-cassette.glb':'/reference/internal-study/assembly-before.glb')));
for(const s of scenes){s.scene.fog=null;for(const child of s.scene.children)if(child.isMesh||child.isGroup)child.visible=false;}
groups.push(...await Promise.all(scenes.map((s,i)=>asset(s,i?'/assets/archive-assembly.glb':'/reference/internal-study/assembly-before.glb'))));
for(const s of scenes){
const grid=new THREE.GridHelper(8,32,0xaaa99d,0xbab9ae);grid.rotation.x=Math.PI/2;grid.position.z=-.065;s.scene.add(grid);grids.push(grid);
}
ready=true;draw();
}catch(e){$('#status').textContent=String(e);console.error(e)}
document.querySelectorAll('[data-angle]').forEach(b=>b.onclick=()=>{angle=b.dataset.angle;document.querySelectorAll('[data-angle]').forEach(x=>x.setAttribute('aria-pressed',String(x===b)));draw()});
let queued=false;const queueDraw=()=>{if(queued)return;queued=true;requestAnimationFrame(()=>{queued=false;draw()})};
$('#display').oninput=queueDraw;$('#display').onchange=queueDraw;$('#zoom').onchange=queueDraw;addEventListener('resize',queueDraw);
</script></html>
Binary file not shown.
+203
View File
@@ -0,0 +1,203 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Rhine Lab · 光影对照</title>
<link rel="stylesheet" href="/reference/wave-compare.css" />
<style>
#original {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
}
.caption span {
max-width: 60%;
text-align: right;
}
.shot[aria-pressed="true"] {
background: #353b30;
color: #f4f0e9;
}
.scene {
pointer-events: none;
}
#status {
font-size: 12px;
color: #666b5d;
margin: 18px 0;
}
</style>
</head>
<body>
<style>
.view::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
z-index: 1;
background:
linear-gradient(
180deg,
#eae5e1 0%,
rgba(234, 229, 225, 0.82) 11%,
transparent 40%
),
linear-gradient(0deg, rgba(234, 229, 225, 0.94) 0%, transparent 19%),
radial-gradient(
ellipse at 48% 48%,
transparent 35%,
rgba(234, 229, 225, 0.38) 85%
);
}
#original {
z-index: 2;
}
</style>
<main>
<header>
<div>
<span class="eyebrow">RHINE LAB / LIGHT STUDY</span>
<h1>光影对照</h1>
</div>
<a href="/?scene=archive" target="_blank" rel="noopener"
>体验当前版本 ↗</a
>
</header>
<p class="intro">
主界面已恢复原始基线的运动、颜色与光照。右侧保留光影实验供回看。
</p>
<section class="toolbar">
<label
>左侧
<select id="source">
<option value="original">原视频</option>
<option value="baseline">基线(当前采用)</option>
</select></label
><button class="shot" data-frame="690">27.60 秒 · 阵列入场</button
><button class="shot" data-frame="729">29.16 秒 · 波峰</button
><button class="shot" data-frame="787" aria-pressed="true">
31.48 秒 · 阵列</button
><button class="shot" data-frame="975">39.00 秒 · 特写</button>
</section>
<section class="views">
<article>
<div class="caption">
<h2 id="left-title">原视频</h2>
<span>同一关键帧</span>
</div>
<div class="view" id="before">
<div class="scene"></div>
<img
id="original"
src="/reference/motion/0787.jpg"
alt="原视频第 787 帧"
/>
</div>
</article>
<article>
<div class="caption">
<h2>光影实验</h2>
<span>相同模型与镜头</span>
</div>
<div class="view" id="after"><div class="scene"></div></div>
</article>
</section>
<p id="status" role="status">正在加载两份实时场景…</p>
<footer>
可切换左侧为基线(当前采用),直接观察光影变化。原片图片仅用于本地审阅。<a
href="/reference/wave-compare.html"
>运动版本对比 ↗</a
>
</footer>
</main>
<script type="module">
import { ArchiveScene } from "/src/scene.ts";
import { baselineSelectionWave, smooth } from "/src/motion.ts";
const $ = (q) => document.querySelector(q),
scenes = [
new ArchiveScene(
$("#before .scene"),
baselineSelectionWave,
false,
"baseline",
),
new ArchiveScene(
$("#after .scene"),
baselineSelectionWave,
false,
"refined",
),
];
const draws = scenes.map((s) => s.composer.render.bind(s.composer));
scenes.forEach((s) => (s.composer.render = () => {}));
let current = 787,
ready = false;
function render() {
const shot = current / 25 - 5,
zoom =
0.55 * smooth((shot - 27.3) / 1.65) +
0.45 * smooth((shot - 29) / 5);
scenes.forEach((s, i) => {
s.update(100, { reveal: 1, lift: 0, zoom, time: shot });
if (i === 1 || $("#source").value === "baseline") draws[i]();
});
$("#original").src =
`/reference/motion/${String(current).padStart(4, "0")}.jpg`;
$("#original").alt = `原视频第 ${current} 帧`;
$("#original").hidden = $("#source").value !== "original";
$("#left-title").textContent =
$("#source").value === "original" ? "原视频" : "基线(当前采用)";
document
.querySelectorAll("[data-frame]")
.forEach((b) =>
b.setAttribute(
"aria-pressed",
String(Number(b.dataset.frame) === current),
),
);
const delta =
scenes[0].camera.position.distanceTo(scenes[1].camera.position) +
scenes[0].model.position.distanceTo(scenes[1].model.position);
$("#status").textContent =
`${(current / 25).toFixed(2)} 秒 · 模型位置与相机${delta < 1e-8 ? "一致" : "存在偏差"}。查看斜面的透光色、细亮边和层间阴影。`;
}
function resize() {
scenes.forEach((s) => {
s.resize();
s.renderer.setPixelRatio(Math.min(devicePixelRatio, 1.5));
s.composer.setPixelRatio(Math.min(devicePixelRatio, 1.5));
});
if (ready) render();
}
$("#source").onchange = () => {
if (ready) render();
};
document.querySelectorAll("[data-frame]").forEach(
(b) =>
(b.onclick = () => {
current = Number(b.dataset.frame);
if (ready) render();
}),
);
addEventListener("resize", resize);
try {
await document.fonts.ready;
await Promise.all(scenes.map((s) => s.load()));
scenes.forEach((s) => {
s.setMode("hidden");
s.select(0);
});
resize();
ready = true;
render();
} catch (e) {
$("#status").textContent = `加载失败:${e.message}`;
console.error(e);
}
</script>
</body>
</html>
+160
View File
@@ -0,0 +1,160 @@
<!doctype html>
<html lang="zh-CN">
<meta charset="utf-8">
<title>循环阵列运行检查</title>
<style>body{margin:0;background:#e8e5e1;color:#252620;font:13px monospace}#scene{width:1100px;height:620px}pre{white-space:pre-wrap;padding:18px}</style>
<div id="scene"></div><pre id="result">运行实际场景检查…</pre>
<script type="module">
import * as THREE from 'three';
import { ArchiveScene } from '/src/scene.ts';
import { columnFiles, fileLocation } from '/src/data.ts';
import { wrap, fileAtCell, cellKey, COLUMN_SPACING, ROW_SPACING } from '/src/archive-loop.ts';
import { smooth, cinematicField } from '/src/motion.ts';
const scene = new ArchiveScene(document.querySelector('#scene'));
await scene.load();
const draw=scene.composer.render.bind(scene.composer);
scene.composer.render=()=>{};
let time=0,index=0;
const step=(frames=1)=>{for(let f=0;f<frames;f++)scene.update(time+=1/60);};
const errors=[];
const expect=(value,message)=>{if(!value)errors.push(message);};
const memory=Array.from({length:5},(_,lane)=>columnFiles(lane)[0]);
scene.select(index);
scene.setMode('archive');
step(300);
const initial=scene.getStats();
const moves=[];
function move(axis,direction){
const before={...scene.selectedCell};
const lane=fileLocation(index).lane;
if(axis==='row'){
const files=columnFiles(lane);
index=files[wrap(files.indexOf(index)+direction,files.length)];
}else index=memory[wrap(lane+direction,5)];
memory[fileLocation(index).lane]=index;
scene.select(index,{axis,direction});
expect(scene.selectedCell[axis]-before[axis]===direction,'Selection reversed at '+JSON.stringify(before));
expect(fileAtCell(scene.selectedCell)===index,'Wrong file in recycled cell');
step(24);
const stats=scene.getStats();
moves.push({axis,direction,file:index+1,cell:stats.selectedCell,rail:stats.rail,track:stats.columnCamera});
expect(stats.archiveCount>0&&stats.archiveCount<=stats.archiveCapacity,'Invalid dynamic draw count');
expect(new Set(scene.drawnCells.map(cellKey)).size===stats.archiveCount,'Duplicate physical cell');
}
// Cross each seam repeatedly in both directions, including negative cells.
for(let i=0;i<18;i++)move('row',1);
for(let i=0;i<22;i++)move('row',-1);
for(let i=0;i<8;i++)move('lane',1);
for(let i=0;i<12;i++)move('lane',-1);
step(300);
const afterLoops=scene.getStats();
expect(afterLoops.returningFiles===0,'Returning clones did not settle');
expect(Math.abs(afterLoops.modelPosition[0])<0.001,'Array track did not center the selected column');
expect(Math.abs(afterLoops.modelPosition[2]+2.17)<0.001,'Depth track did not center the selected row');
// Confirm that compacted GPU indices still select their physical cells.
scene.scene.updateMatrixWorld(true);
let pickSamples=0;
for(const x of [-.6,0,.6])for(const y of [-.5,0,.5]) {
scene.raycaster.setFromCamera(new THREE.Vector2(x,y),scene.camera);
const hit=scene.raycaster.intersectObjects([scene.instances[0],scene.model],true)[0];
if(hit?.instanceId===undefined)continue;
const matrix=new THREE.Matrix4();scene.instances[0].getMatrixAt(hit.instanceId,matrix);
const point=new THREE.Vector3().setFromMatrixPosition(matrix);
const expected={lane:Math.round((point.x+scene.columnCamera.value)/COLUMN_SPACING+2),row:Math.round((point.z-scene.rail.value)/ROW_SPACING+15.5)};
const rect=scene.renderer.domElement.getBoundingClientRect();
const picked=scene.pickCell(rect.left+(x+1)*rect.width/2,rect.top+(1-y)*rect.height/2);
expect(picked&&cellKey(picked)===cellKey(expected),'Packed instance picked wrong archive');pickSamples++;
}
expect(pickSamples>0,'No packed-array picking samples');
// A large logical origin must be reduced without changing physical positions,
// spring velocities, current document or idle-motion phase.
const physical=()=>({
x:(scene.selectedCell.lane-2)*COLUMN_SPACING-scene.columnCamera.value,
z:(scene.selectedCell.row-15.5)*ROW_SPACING+scene.rail.value,
phaseRow:scene.selectedCell.row+scene.coordinateOrigin.row,
phaseLane:scene.selectedCell.lane+scene.coordinateOrigin.lane,
rowVelocity:scene.rail.velocity,laneVelocity:scene.columnCamera.velocity,
});
const beforeRebase=physical();
const offset={lane:2500,row:-3200};
scene.selectedCell.lane+=offset.lane;scene.selectedCell.row+=offset.row;
scene.coordinateOrigin.lane-=offset.lane;scene.coordinateOrigin.row-=offset.row;
scene.laneFocus.value+=offset.lane;scene.shoulder.value+=offset.row;
scene.columnCamera.value+=offset.lane*COLUMN_SPACING;
scene.rail.value-=offset.row*ROW_SPACING;
for(const pulse of scene.pulses){pulse.lane+=offset.lane;pulse.row+=offset.row;}
scene.rebaseCoordinates();
const afterRebase=physical();
expect(Object.keys(beforeRebase).every(k=>Math.abs(beforeRebase[k]-afterRebase[k])<1e-9),'Logical rebasing changed the visible state');
expect(fileAtCell(scene.selectedCell)===index,'Rebasing changed selected document');
// Extraction stays vertical at a wrapped position, and return still aligns
// before lowering. Re-selecting its cell restores the same returning copy.
scene.setMode('detail');
step(300);
const detail=scene.getStats();
expect(detail.canInspect&&detail.extraction===4.05,'Wrapped file cannot enter inspection');
scene.rotation=scene.targetRotation=0.8;step(1);
const height=scene.model.position.y;
scene.setMode('archive');step(20);
const aligning=scene.getStats();
expect(aligning.returnPhase==='aligning','Wrapped card lowered before alignment');
expect(Math.abs(scene.model.position.y-height)<1e-8,'Wrapped card did not hold its height');
const oldCell={...scene.selectedCell};
const oldIndex=index;
move('row',1);
const returning=scene.outgoing.find(o=>cellKey(o.cell)===cellKey(oldCell));
expect(Boolean(returning),'Wrapped outgoing card lost its physical cell');
const returningLift=returning?.lift.value;
scene.select(oldIndex,{cell:oldCell});
expect(Math.abs(scene.lift.value-returningLift)<1e-9,'Re-selection lost returning height');
step(300);
const finalStats=scene.getStats();
// Replaying the reference animation after looping restores the original grid.
scene.setMode('hidden');scene.select(0);
const matrix=new THREE.Matrix4(),position=new THREE.Vector3();
let referenceError=0,referenceVisible=0;
for(const shot of [22.60,24.16,26.56,34]){
const zoom=0.55*smooth((shot-27.3)/1.65)+0.45*smooth((shot-29)/5);
scene.update(time+=1/60,{reveal:1,lift:0,zoom,time:shot});
const entryZ=-23*(1-Math.min(1,Math.max(0,(shot-21.92)/0.75)))**2;
let visible=0;
for(let i=0;i<scene.instances[0].count;i++){
scene.instances[0].getMatrixAt(i,matrix);
const scale=new THREE.Vector3().setFromMatrixScale(matrix).length();
if(scale>0.001)visible++;
if(i>=160)expect(scale===0,'Extra interactive column entered reference animation');
if(i<160&&i!==76){
position.setFromMatrixPosition(matrix);
const {lane,row}=scene.drawnCells[i];
expect(lane>=0&&lane<5&&row>=0&&row<32,'Extra cell entered reference animation');
const expected=new THREE.Vector3((lane-2)*5.2,-4.6+cinematicField(row,lane,shot,12,2),(row-15.5)*0.62+entryZ);
referenceError=Math.max(referenceError,position.distanceTo(expected));
}
}
referenceVisible=visible;
expect(visible===159,'Reference animation instance count changed');
}
expect(referenceError<1e-5,'Reference animation grid changed');
const opening=[];
const container=document.querySelector('#scene');
container.dataset.layout='opening';
for(const [width,height] of [[1920,1080],[3840,1080],[1080,1920]]) {
container.style.width=width+'px';container.style.height=height+'px';scene.resize();
for(const shot of [22.2,24,25.8]) {
scene.update(time+=1/60,{reveal:1,lift:0,zoom:0,time:shot});
opening.push({width,height,shot,count:scene.drawnCells.length});
expect(scene.drawnCells.some(c=>c.row<0||c.row>=32||c.lane<0||c.lane>=5),'Normal opening retained finite grid');
expect(scene.drawnCells.length>0,'Normal opening has no cards');
}
}
delete container.dataset.layout;container.style.width='1100px';container.style.height='620px';scene.resize();
scene.update(time+=1/60,{reveal:1,lift:0,zoom:1,time:34});
expect(scene.drawnCells.length===159,'Explicit reference did not restore fixed grid after responsive opening');
draw();
document.querySelector('#result').textContent=JSON.stringify({passed:errors.length===0,errors,opening,moves:moves.length,pickSamples,initialPool:initial.archiveCount,reference:{visible:referenceVisible,error:referenceError},afterLoops,rebasing:{before:beforeRebase,after:afterRebase},detail:{canInspect:detail.canInspect,lift:detail.extraction},aligning:{phase:aligning.returnPhase,height:aligning.modelPosition[1]},lastMoves:moves.slice(-5),finalStats},null,2);
if(errors.length)throw new Error(errors.join('\n'));
</script>
</html>
+17
View File
@@ -0,0 +1,17 @@
from pathlib import Path
import sys
sys.path.insert(0,'.tools')
import cv2
import numpy as np
sources=[
'C:/Users/LBLC/AppData/Local/Temp/codex-clipboard-159c6dd1-8880-4ed7-8638-037d13c5a65b.png',
'C:/Users/LBLC/AppData/Local/Temp/codex-clipboard-c945190d-1847-4dcd-9595-333623f8d757.png',
'C:/Users/LBLC/AppData/Local/Temp/codex-clipboard-c37ac867-c3a5-4f49-bf2d-d66def143131.png']
frames=[(p,cv2.resize(cv2.imread(str(p)),(480,270)).astype(float)) for p in Path('reference/motion').glob('[0-9]*.jpg')]
for source in sources:
im=cv2.imread(source)[:1080,:1920]
im=cv2.resize(im,(480,270)).astype(float)
mask=~((im[:,:,2]>170)&(im[:,:,1]>130)&(im[:,:,0]<160))
mask[:55,:90]=False
scores=sorted((np.mean(np.abs(im-f)[mask]),p.stem) for p,f in frames)
print(Path(source).name,[(int(n)/25,round(float(s),2)) for s,n in scores[:4]])
+53
View File
@@ -0,0 +1,53 @@
"""Sample the continuous infinity contour against the reference's black ink."""
import sys,json
from pathlib import Path
root=Path(__file__).resolve().parent
sys.path.insert(0,str(root.parent/'.tools'))
import cv2
import numpy as np
from PIL import Image,ImageDraw
out=root/'boot-frames'
# Continuous version of the shared Bezier, with the printed logo's small gap filled.
curves=[[(295,73),(295,41),(273,15),(240,15)],[(240,15),(221,15),(207,23),(192,38)],[(192,38),(186,43),(181,47),(176,52)],[(176,52),(127,96),(103,128),(70,128)],[(70,128),(38,128),(15,101),(15,70)],[(15,70),(15,39),(37,15),(70,15)],[(70,15),(103,15),(127,48),(156,75)],[(156,75),(182,99),(208,128),(240,128)],[(240,128),(273,128),(295,105),(295,73)]]
p=[]
for c in curves:
t=np.linspace(0,1,300)[:,None];a,b,d,e=np.array(c)
p.extend((1-t)**3*a+3*(1-t)**2*t*b+3*(1-t)*t*t*d+t**3*e)
p=np.array(p);dist=np.r_[0,np.cumsum(np.linalg.norm(np.diff(p,axis=0),axis=1))]
params=np.linspace(0,1,2000,endpoint=False)
points=np.c_[np.interp(params*dist[-1],dist,p[:,0]),np.interp(params*dist[-1],dist,p[:,1])]
results=[]
for f in range(229,487):
im=cv2.imread(str(out/f'{f:04}.jpg'),0)
# Fit the final-size contour to its source bounding box; translation is
# measured from stable leftmost ink once the left loop has been drawn.
crop=im[464:608,490:1130]
yy,xx=np.where(crop<90)
left=float(xx.min()+490) if len(xx) and f>=238 else 815
x=(points[:,0]-2)*(.955)+left
y=(points[:,1]-2)*(.955)+465
gray=cv2.remap(im,x.astype(np.float32)[None,:],y.astype(np.float32)[None,:],cv2.INTER_LINEAR)[0]
mask=(gray<130).astype(np.uint8)
# Remove tiny raster interruptions; do not fill a real moving cut.
mask=cv2.morphologyEx(np.tile(mask,3)[None,:],cv2.MORPH_CLOSE,np.ones((1,8),np.uint8))[0,2000:4000]
changes=np.diff(np.r_[0,mask,0].astype(int))
runs=[(int(a),int(b)) for a,b in zip(np.where(changes==1)[0],np.where(changes==-1)[0]) if b-a>4]
results.append({'frame':f,'left':left,'runs':runs,'coverage':round(float(mask.mean()),4)})
if 247<=f<=268:
mark=im[475:593,int(left):int(left)+294]
n,labels,stats,centers=cv2.connectedComponentsWithStats((mark<110).astype(np.uint8))
symbols=[]
for s,(cx,cy) in zip(stats[1:],centers[1:]):
if s[4]<4 or s[4]>2500 or not 30<cy<95:continue
symbols.append([round(float(cx/.955+2),2),round(float((cy+10)/.955+2),2),int(s[2]),int(s[3]),int(s[4])])
results[-1]['symbols']=symbols
if f in [238,260,350,460]:print(f,'bbox',(int(xx.min()+490),int(yy.min()+464),int(xx.max()+490),int(yy.max()+464)))
(out/'logo-measurements.json').write_text(json.dumps(results,indent=2))
for a,b in [(229,265),(300,487)]:
rows=[r for r in results if a<=r['frame']<b]
img=Image.new('RGB',(1100,len(rows)*12),'#eeeeeb');draw=ImageDraw.Draw(img)
for i,row in enumerate(rows):
draw.text((0,i*12),str(row['frame']),fill='black')
for start,end in row['runs']:draw.rectangle((70+start/2,i*12,70+end/2,i*12+10),fill='black')
img.save(out/f'logo-ink-{a}.jpg')
print('Contour length',dist[-1])
+70
View File
@@ -0,0 +1,70 @@
"""Measure the local 25 fps reference; generated images stay in the ignored folder."""
import sys, json
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / '.tools'))
import cv2
import numpy as np
from PIL import Image, ImageDraw
root = Path(__file__).resolve().parent
out = root / 'boot-frames'
angles = np.linspace(0, 2*np.pi, 1440, endpoint=False)
radii = np.arange(85, 2201, .5)
mx = (960 + radii[:, None]*np.cos(angles)).astype(np.float32)
my = (540 + radii[:, None]*np.sin(angles)).astype(np.float32)
def segments(mask):
# Close sub-degree compression holes, then unwrap at a gap.
mask = np.array(mask, dtype=np.uint8)
mask = cv2.morphologyEx(np.tile(mask, 3)[None,:], cv2.MORPH_CLOSE, np.ones((1,7),np.uint8))[0,1440:2880]
gaps = np.where(mask == 0)[0]
if len(gaps) == 0: return [(0,360)]
origin = gaps[0]
rolled = np.roll(mask, -origin)
edges = np.diff(np.r_[0,rolled,0].astype(int))
return sorted([(((a+origin)%1440)/4,(b-a)/4) for a,b in zip(np.where(edges==1)[0],np.where(edges==-1)[0]) if b-a>12], key=lambda x:-x[1])
rows=[]
for f in range(487,569):
im=cv2.imread(str(out/f'{f:04}.jpg'))
polar=cv2.remap(im,mx,my,cv2.INTER_LINEAR,borderMode=cv2.BORDER_CONSTANT,borderValue=(128,128,128))
black=polar.max(axis=2)<100
white=polar.min(axis=2)>248
row={'frame':f}
# Outer arcs occupy much more of a concentric radius than text/background.
for name,mask,lo,hi in [('outer',black,240,2200),('white',white,220,1500),('inner',black,85,140)]:
valid=(radii>=lo)&(radii<=hi)
counts=mask.sum(axis=1)*valid
idx=int(np.argmax(counts))
row[name]=[float(radii[idx]),segments(mask[max(0,idx-1):idx+2].any(axis=0))[:2]]
orange=(im[:,:,2]>190)&(im[:,:,1]>65)&(im[:,:,1]<190)&(im[:,:,0]<100)
n,labels,stats,centers=cv2.connectedComponentsWithStats(orange.astype(np.uint8))
dots=[(float(x),float(y),int(s[4])) for s,(x,y) in zip(stats[1:],centers[1:]) if s[4]>8 and 500<x<1420 and 200<y<880]
row['dots']=dots
rows.append(row)
(out/'measurements.json').write_text(json.dumps(rows,indent=2))
brand=[]
for f in range(277,316):
im=cv2.imread(str(out/f'{f:04}.jpg'),0)
bands=[]
for top,bottom in [(120,158),(163,181),(187,215)]:
roi=im[top:bottom,40:700]
yy,xx=np.where(roi<185)
bands.append([int(xx.min()+40),int(np.percentile(roi[yy,xx],10))] if len(xx) else None)
brand.append([f,*bands])
(out/'brand-measurements.json').write_text(json.dumps(brand,indent=2))
company=[]
for f in range(588,622):
im=cv2.imread(str(out/f'{f:04}.jpg'),0)
xs=np.where(im[472,748:1170]<170)[0]
company.append([f,int(xs.max()+748) if len(xs) else None])
(out/'company-measurements.json').write_text(json.dumps(company,indent=2))
for name,frames,crop,size,cols in [('ring-detail',range(518,546),(655,235,1265,845),(366,366),7),('company-detail',range(588,609),(748,468,1168,530),(630,93),3),('brand-detail',range(277,307),(40,110,650,225),(610,115),3)]:
frames=list(frames);w,h=size
sheet=Image.new('RGB',(cols*w,((len(frames)+cols-1)//cols)*(h+25)),'#242424');draw=ImageDraw.Draw(sheet)
for i,f in enumerate(frames):
x=i%cols*w;y=i//cols*(h+25)
sheet.paste(Image.open(out/f'{f:04}.jpg').crop(crop).resize(size),(x,y+25))
draw.text((x+8,y+5),f'{f} / {f/25:.2f}s',fill='white')
sheet.save(out/f'{name}.jpg',quality=96)
print(json.dumps(rows[::5],indent=2))
+45
View File
@@ -0,0 +1,45 @@
"""Inspect every source frame around the authorization-to-orbit transition."""
import sys,json
from pathlib import Path
root=Path(__file__).resolve().parent
sys.path.insert(0,str(root.parent/'.tools'))
import cv2
import numpy as np
from PIL import Image,ImageDraw
out=root/'boot-frames'
rows=[]
for f in range(540,569):
im=cv2.imread(str(out/f'{f:04}.jpg'),0)
row={'frame':f,'sides':[]}
for side,(cx,cy) in enumerate([(830,552),(1090,528)]):
# The source circles' centers move towards the horizontal axis. A fixed
# annulus clips the lower/upper stroke late in the animation.
cy=round(np.interp(f,[544,548,551,555,560,568],[558,557,552,546,542,540]) if side==0 else np.interp(f,[544,548,551,555,560,568],[520,523,527,533,537,539]))
y,x=np.mgrid[cy-48:cy+49,cx-48:cx+49]
radius=np.hypot(x-cx,y-cy)
mask=(im[cy-48:cy+49,cx-48:cx+49]<130)&(radius>33)&(radius<44)
if f<547:
# Permission lettering occupies these rows and is not part of the arc.
mask&=(y<526)|(y>554)
xx,yy=x[mask],y[mask]
if len(xx)<8:row['sides'].append(None);continue
a,b,c=np.linalg.lstsq(np.c_[2*xx,2*yy,np.ones(len(xx))],xx*xx+yy*yy,rcond=None)[0]
r=np.sqrt(c+a*a+b*b)
angles=np.sort(np.mod(np.degrees(np.arctan2(yy-b,xx-a)),360))
gaps=np.diff(np.r_[angles,angles[0]+360]);i=int(np.argmax(gaps))
start=angles[(i+1)%len(angles)];sweep=360-gaps[i]
row['sides'].append([round(float(v),2) for v in [a,b,r,start,sweep]])
roi=im[478:603,898:1023]
n,labels,stats,centers=cv2.connectedComponentsWithStats((roi<120).astype(np.uint8))
row['dots']=[[round(float(x+898),2),round(float(y+478),2),int(s[4]),round(float(np.sqrt(s[4]/np.pi)),2)] for s,(x,y) in zip(stats[1:],centers[1:]) if s[4]>=3 and s[2]<90 and s[3]<90]
rows.append(row)
(out/'scan-detail-measurements.json').write_text(json.dumps(rows,indent=2))
for start,end in [(540,554),(554,569)]:
w,h=760,440;cols=3
sheet=Image.new('RGB',(w*cols,((end-start+cols-1)//cols)*(h+24)),'#242424');d=ImageDraw.Draw(sheet)
for i,f in enumerate(range(start,end)):
x=i%cols*w;y=i//cols*(h+24)
sheet.paste(Image.open(out/f'{f:04}.jpg').crop((770,430,1150,650)).resize((w,h)),(x,y+24))
d.text((x+8,y+5),f'{f} / {f/25:.2f}s',fill='white')
sheet.save(out/f'scan-center-{start}.jpg',quality=96)
print(json.dumps(rows,indent=2))
+184
View File
@@ -0,0 +1,184 @@
<!doctype html>
<html lang="zh-CN">
<meta charset="utf-8" />
<title>标题与查看器过渡检查</title>
<style>
body {
margin: 0;
background: #eae5e1;
color: #252620;
font: 13px monospace;
}
iframe {
width: 1100px;
height: 620px;
border: 0;
}
pre {
padding: 16px;
white-space: pre-wrap;
}
</style>
<iframe src="/?scene=archive" title="真实交互场景"></iframe>
<pre id="result">正在检查…</pre>
<script type="module">
import { records } from "/src/data.ts";
import { createRollingText } from "@kitlangton/rolling-number";
import "@kitlangton/rolling-number/styles.css";
const iframe = document.querySelector("iframe"),
result = document.querySelector("#result");
const checks = [];
const pause = (ms) => new Promise((r) => setTimeout(r, ms));
function check(ok, label) {
if (!ok) throw new Error(label);
checks.push(label);
}
async function until(fn) {
const start = performance.now();
while (!fn()) {
if (performance.now() - start > 10000) throw new Error("等待状态超时");
await pause(25);
}
}
try {
await until(
() =>
iframe.contentWindow.rhine?.stats().loaded &&
!iframe.contentDocument.querySelector("#loading") &&
iframe.contentWindow.rhine.stats().mode === "archive",
);
const win = iframe.contentWindow,
doc = iframe.contentDocument,
app = win.rhine;
app.archive();
await pause(300);
const title = doc.querySelector("#selected-title");
const value = () => title.querySelector(".rn-value")?.textContent;
const moving = () =>
title
.getAnimations({ subtree: true })
.some((a) => a.playState === "running");
app.select(1);
await until(moving);
check(
value() === records[1].title,
"普通切换播放文字滚动并立即更新可访问标题",
);
app.select(2);
app.select(3);
check(value() === records[3].title, "快速切换以最后选择为目标");
check(
win.getComputedStyle(title, "::after").content === "none",
"快切期间不再显示黑色横条",
);
app.select(4);
app.select(5);
await pause(60);
await until(() => !moving());
check(value() === records[5].title, "停止后滚动落到最终标题");
const finalGlyphs = [...title.querySelectorAll(".rn-visual .rn-face")]
.map((face) => face.textContent)
.join("");
check(finalGlyphs === records[5].title, "滚动完成后没有残留的旧文字");
app.select(6);
app.select(7);
app.detail();
check(
!moving() && value() === records[7].title,
"切进详情结束标题滚动并保留最终文字",
);
await until(() => app.stats().canInspect);
const opener = doc.querySelector('[data-action="model-viewer"]');
opener.focus();
opener.click();
const viewer = doc.querySelector(".model-viewer");
check(
viewer.dataset.transition === "opening" && !viewer.hidden,
"查看器保留进入动画阶段",
);
const before = win.getComputedStyle(viewer).opacity;
viewer
.querySelector("canvas")
.dispatchEvent(
new win.KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
);
check(
viewer.dataset.transition === "closing" && !viewer.hidden,
"进入途中 Esc 转入退出动画",
);
check(
doc.querySelector("#detail-ui").inert,
"退出动画结束前详情仍不接收输入",
);
await until(() => viewer.hidden);
check(
!doc.querySelector("#detail-ui").inert && doc.activeElement === opener,
"退出结束恢复详情与原按钮焦点",
);
opener.click();
await until(
() =>
viewer.dataset.transition === "open" &&
JSON.parse(viewer.dataset.stats || "{}").ready,
);
check(!viewer.hidden, "中途关闭后可重新打开并加载模型");
viewer.querySelector('[data-viewer="explode"]').click();
await pause(100);
viewer.querySelector('[data-viewer="close"]').click();
viewer.querySelector('[data-viewer="close"]').click();
await until(() => viewer.hidden);
check(
!doc.querySelector("#detail-ui").inert,
"拆解中重复关闭也能完整退出",
);
const host = document.createElement("span");
host.style.cssText =
"display:inline-block;font:24px system-ui;margin:16px";
document.body.append(host);
const text = createRollingText(host, {
text: "初始",
transition: "direct",
stagger: "none",
duration: 460,
});
await pause(100);
text.update({ text: "长期实验研究记录" });
await pause(90);
text.update({ text: "光" });
await pause(60);
text.update({ text: "光学研究 🧬 / A-01" });
await pause(100);
await until(
() =>
!host
.getAnimations({ subtree: true })
.some((a) => a.playState === "running"),
);
check(
host.querySelector(".rn-value").textContent === "光学研究 🧬 / A-01",
"长短标题与中文、符号混排能正确收敛",
);
check(
host.querySelectorAll(".rn-slot").length <=
[...new Intl.Segmenter().segment("光学研究 🧬 / A-01")].length,
"连续替换后回收旧字槽",
);
text.update({ text: "最终标题", animated: false });
check(
host.querySelector(".rn-value").textContent === "最终标题" &&
host.getAnimations({ subtree: true }).length === 0,
"减少动态效果立即显示最终标题",
);
text.destroy();
host.remove();
result.textContent = JSON.stringify({ passed: true, checks }, null, 2);
} catch (error) {
result.textContent = JSON.stringify(
{ passed: false, checks, error: error.message },
null,
2,
);
console.error(error);
}
</script>
</html>
+40
View File
@@ -0,0 +1,40 @@
<!doctype html>
<html lang="zh-CN">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Rhine Lab · 手机实测</title>
<style>
*{box-sizing:border-box}body{margin:0;background:#e8e5e1;font-family:system-ui,sans-serif;color:#252820}iframe{position:fixed;inset:0;width:100%;height:100dvh;border:0}details{position:fixed;z-index:3;bottom:max(10px,env(safe-area-inset-bottom));left:12px;right:12px;background:#eeebe3f5;border:1px solid #bcb4a5;padding:12px;font-size:13px;box-shadow:0 6px 30px #33291820}details[open]{max-height:70dvh;overflow:auto}summary{cursor:pointer;min-height:28px;line-height:28px}h1{font-size:19px;margin:12px 0}p{line-height:1.7;margin:10px 0}button,a{font:inherit;min-height:44px;padding:10px 14px;border:1px solid #9a9385;background:#e8e5e1;color:inherit;text-decoration:none;display:inline-flex;align-items:center;justify-content:center}button:disabled{opacity:.5}.actions{display:flex;flex-wrap:wrap;gap:8px}output{display:block;font-size:12px;white-space:pre-wrap;line-height:1.7;margin-top:12px}#progress{position:fixed;top:max(8px,env(safe-area-inset-top));left:50%;transform:translateX(-50%);padding:7px 12px;background:#252820;color:#eee;z-index:4;font-size:11px;white-space:nowrap;pointer-events:none}#progress:empty{display:none}
</style>
<iframe id="app" title="莱茵生命档案" src="/?scene=archive"></iframe>
<div id="progress" role="status"></div>
<details open id="panel"><summary>手机实测 · 点击收起以体验完整画面</summary>
<h1>iPhone / Safari 校准</h1>
<p>先竖屏运行一次,再横屏运行一次。每次约 45 秒,期间保持页面在前台,测试会依次浏览档案、解密和拆解模型。结果只包含屏幕、画质与帧间隔信息,会回传到这台电脑。</p>
<div class="actions"><button id="run">运行当前方向测试</button><a href="/?scene=archive" target="_blank">打开完整体验</a><button id="download" disabled>下载结果</button></div>
<p>手动体验:左右滑动切列,上下滑动切档;详情正文可滚动;360° 查看器支持单指旋转、双指缩放和平移。请检查搜索键盘弹出后能否输入和关闭窗口。</p>
<output id="result">等待档案载入…</output></details>
<script type="module">
const app=document.querySelector('#app'),panel=document.querySelector('#panel'),result=document.querySelector('#result'),progress=document.querySelector('#progress'),run=document.querySelector('#run'),download=document.querySelector('#download');
let report=null;
const wait=ms=>new Promise(r=>setTimeout(r,ms));
const win=()=>app.contentWindow,doc=()=>win().document,stats=()=>win().rhine?.stats();
async function until(test, timeout=30000){const start=performance.now();while(!test()){if(performance.now()-start>timeout)throw Error('档案载入或过渡超时,请重试');await wait(100)}}
const click=selector=>{const button=doc().querySelector(selector);if(!button)throw Error(`操作不可用:${selector}`);button.click()};
function summarize(values){const v=[...values].sort((a,b)=>a-b),p=q=>Math.round(v[Math.min(v.length-1,Math.floor(v.length*q))]*100)/100;return {frames:v.length,averageFps:Math.round(1000/(v.reduce((a,b)=>a+b,0)/v.length)*10)/10,p50:p(.5),p95:p(.95),p99:p(.99),over33ms:v.filter(n=>n>33.34).length,over50ms:v.filter(n=>n>50).length}}
async function sample(name, duration=6000, action){progress.textContent=`正在测量:${name}`;let previous=null,start=null,lastAction=0;const values=[];await new Promise((resolve,reject)=>{function frame(t){if(document.hidden){reject(Error('页面进入后台,测量已停止,请在前台重试'));return}if(start===null)start=t;if(previous!==null)values.push(t-previous);previous=t;if(action&&t-lastAction>550){lastAction=t;action()}if(t-start>=duration){resolve();return}win().requestAnimationFrame(frame)}win().requestAnimationFrame(frame)});return {name,...summarize(values),quality:JSON.parse(doc().querySelector('#three-scene').dataset.renderQuality||'{}'),stats:stats()}}
app.addEventListener('load',async()=>{await until(()=>stats()?.ready,60000);result.textContent='已就绪。可运行测试,或收起面板自由体验。'});
run.onclick=async()=>{run.disabled=true;download.disabled=true;panel.open=false;const orientation=innerWidth>innerHeight?'landscape':'portrait';report={version:1,startedAt:new Date().toISOString(),userAgent:navigator.userAgent,viewport:{width:innerWidth,height:innerHeight,dpr:devicePixelRatio,orientation},samples:[]};try{
await until(()=>stats()?.ready,60000);if(doc().querySelector('.model-viewer:not([hidden])'))click('[data-viewer="close"]');await wait(350);win().rhine.archive();await wait(1800);
report.samples.push(await sample('阵列预览'));
report.samples.push(await sample('连续切档',6000,()=>click('[data-action="next"]')));
await wait(1600);win().rhine.detail();report.samples.push(await sample('抽取与解密',9000));await until(()=>stats().decryption.clarity===1);report.samples.push(await sample('清晰详情',4000));
click('.viewer-open');await until(()=>JSON.parse(doc().querySelector('.model-viewer')?.dataset.stats||'{}').ready);await wait(700);click('[data-viewer="explode"]');report.samples.push(await sample('模型拆解',6000));click('[data-viewer="assemble"]');report.samples.push(await sample('模型重组',4000));click('[data-viewer="close"]');
if((innerWidth>innerHeight?'landscape':'portrait')!==orientation)throw Error('测试中屏幕方向发生变化,请保持同一方向重新运行');
report.finishedAt=new Date().toISOString();result.textContent=report.samples.map(s=>`${s.name}:${s.averageFps} FPS,P95 ${s.p95}ms`).join('\n');
try{const response=await fetch('/__mobile-review',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(report)});if(!response.ok)throw Error();result.textContent+='\n结果已回传电脑。'}catch{result.textContent+='\n自动回传不可用,请下载结果。'}
download.disabled=false;
}catch(e){result.textContent=e.message}finally{progress.textContent='';run.disabled=false;panel.open=true}};
download.onclick=()=>{if(!report)return;const url=URL.createObjectURL(new Blob([JSON.stringify(report,null,2)],{type:'application/json'}));const link=document.createElement('a');link.href=url;link.download=`rhine-mobile-${report.viewport.orientation}.json`;link.click();setTimeout(()=>URL.revokeObjectURL(url),30000)};
</script>
</html>
+230
View File
@@ -0,0 +1,230 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Rhine Lab · 模型细节对照</title>
<link rel="stylesheet" href="/reference/wave-compare.css" />
<style>
#original {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
}
.caption span {
max-width: 60%;
text-align: right;
}
.shot[aria-pressed="true"] {
background: #353b30;
color: #f4f0e9;
}
.scene {
pointer-events: none;
}
#status {
font-size: 12px;
color: #666b5d;
margin: 18px 0;
}
</style>
</head>
<body>
<style>
.view::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
z-index: 1;
background:
linear-gradient(
180deg,
#eae5e1 0%,
rgba(234, 229, 225, 0.82) 11%,
transparent 40%
),
linear-gradient(0deg, rgba(234, 229, 225, 0.94) 0%, transparent 19%),
radial-gradient(
ellipse at 48% 48%,
transparent 35%,
rgba(234, 229, 225, 0.38) 85%
);
}
#original {
z-index: 2;
}
</style>
<main>
<header>
<div>
<span class="eyebrow">RHINE LAB / MODEL STUDY</span>
<h1>模型细节对照</h1>
</div>
<a href="/?scene=archive" target="_blank" rel="noopener"
>体验当前版本 ↗</a
>
</header>
<p class="intro">
放大比较原片、细化前与细化后。两份实时场景使用相同镜头、基线光照和运动。
</p>
<section class="toolbar">
<label
>左侧
<select id="source">
<option value="original">原视频</option>
<option value="baseline">细化前</option>
</select></label
><button class="shot" data-frame="690">27.60 秒 · 阵列入场</button
><button class="shot" data-frame="729">29.16 秒 · 波峰</button
><button class="shot" data-frame="787" aria-pressed="true">
31.48 秒 · 阵列</button
><button class="shot" data-frame="975">39.00 秒 · 特写</button>
<label
>放大
<select id="magnify">
<option value="1">完整画面</option>
<option value="2">2× 模型上半部</option>
<option value="3">3× 压槽与螺钉</option>
</select></label
>
</section>
<section class="views">
<article>
<div class="caption">
<h2 id="left-title">原视频</h2>
<span>同一关键帧</span>
</div>
<div class="view" id="before">
<div class="scene"></div>
<img
id="original"
src="/reference/motion/0975.jpg"
alt="原视频第 787 帧"
/>
</div>
</article>
<article>
<div class="caption">
<h2>细化后</h2>
<span>相同模型与镜头</span>
</div>
<div class="view" id="after"><div class="scene"></div></div>
</article>
</section>
<p id="status" role="status">正在加载两份实时场景…</p>
<footer>
可切换左侧为细化前;放大观察顶部与圆环。原片仅用于本地审阅。<a
href="/reference/wave-compare.html"
>运动版本对比 ↗</a
>
</footer>
</main>
<script type="module">
import { ArchiveScene } from "/src/scene.ts";
import { baselineSelectionWave, smooth } from "/src/motion.ts";
const $ = (q) => document.querySelector(q),
scenes = [
new ArchiveScene(
$("#before .scene"),
baselineSelectionWave,
false,
"baseline",
),
new ArchiveScene(
$("#after .scene"),
baselineSelectionWave,
false,
"baseline",
),
];
const draws = scenes.map((s) => s.composer.render.bind(s.composer));
scenes.forEach((s) => (s.composer.render = () => {}));
let current = 975,
ready = false;
function render() {
const shot = current / 25 - 5,
zoom =
0.55 * smooth((shot - 27.3) / 1.65) +
0.45 * smooth((shot - 29) / 5);
scenes.forEach((s, i) => {
s.update(100, { reveal: 1, lift: 0, zoom, time: shot });
if (i === 1 || $("#source").value === "baseline") draws[i]();
});
$("#original").src =
`/reference/motion/${String(current).padStart(4, "0")}.jpg`;
$("#original").alt = `原视频第 ${current} 帧`;
$("#original").hidden = $("#source").value !== "original";
$("#left-title").textContent =
$("#source").value === "original" ? "原视频" : "细化前";
document
.querySelectorAll("[data-frame]")
.forEach((b) =>
b.setAttribute(
"aria-pressed",
String(Number(b.dataset.frame) === current),
),
);
const delta =
scenes[0].camera.position.distanceTo(scenes[1].camera.position) +
scenes[0].model.position.distanceTo(scenes[1].model.position);
$("#status").textContent =
`${(current / 25).toFixed(2)} 秒 · 模型位置与相机${delta < 1e-8 ? "一致" : "存在偏差"}。比较环形阶梯、顶部压槽、螺钉与边框接缝。`;
}
function resize() {
scenes.forEach((s) => {
s.resize();
s.renderer.setPixelRatio(1);
s.composer.setPixelRatio(1);
s.renderer.setSize(1920, 1080, false);
s.composer.setSize(1920, 1080);
s.renderer.domElement.style.width = "100%";
s.renderer.domElement.style.height = "100%";
});
if (ready) render();
}
$("#magnify").onchange = () => {
const scale = Number($("#magnify").value);
document.querySelectorAll(".scene, #original").forEach((el) => {
el.style.transformOrigin = scale === 3 ? "57% 25%" : "55% 38%";
el.style.transform = `scale(${scale})`;
});
};
$("#source").onchange = () => {
if (ready) render();
};
document.querySelectorAll("[data-frame]").forEach(
(b) =>
(b.onclick = () => {
current = Number(b.dataset.frame);
if (ready) render();
}),
);
addEventListener("resize", resize);
try {
await document.fonts.ready;
await Promise.all(
scenes.map((s, i) =>
s.load(
i === 0
? "/reference/clear-study/archive-before.glb"
: "/assets/archive-cassette.glb",
),
),
);
scenes.forEach((s) => {
s.setMode("hidden");
s.select(0);
});
resize();
ready = true;
render();
} catch (e) {
$("#status").textContent = `加载失败:${e.message}`;
console.error(e);
}
</script>
</body>
</html>
Binary file not shown.
+228
View File
@@ -0,0 +1,228 @@
<!doctype html>
<html lang="zh-CN">
<meta charset="utf-8" />
<title>档案导航文字与刻度过渡检查</title>
<style>
body {
margin: 0;
background: #eae5e1;
font: 13px monospace;
}
iframe {
width: 1100px;
height: 620px;
border: 0;
}
pre {
padding: 16px;
white-space: pre-wrap;
}
</style>
<iframe src="/?scene=archive" title="真实交互场景"></iframe>
<pre id="result">正在检查…</pre>
<script type="module">
import { records, columnFiles, archiveColumns } from "/src/data.ts";
const frame = document.querySelector("iframe"),
result = document.querySelector("#result"),
checks = [];
const pause = (ms) => new Promise((r) => setTimeout(r, ms));
function check(ok, label) {
if (!ok) throw new Error(label);
checks.push(label);
}
async function until(fn) {
const start = performance.now();
while (!fn()) {
if (performance.now() - start > 12000) throw new Error("等待状态超时");
await pause(25);
}
}
try {
await until(
() =>
frame.contentWindow.rhine?.stats().loaded &&
!frame.contentDocument.querySelector("#loading"),
);
const win = frame.contentWindow,
doc = frame.contentDocument,
app = win.rhine;
const q = (s) => doc.querySelector(s),
value = (s) => q(s).querySelector(".rn-value")?.textContent;
const moving = (s) =>
q(s)
.getAnimations({ subtree: true })
.some((a) => a.playState === "running");
const height = (b) =>
parseFloat(win.getComputedStyle(b, "::before").height);
const button = (a) => q(`[data-action="${a}"]`);
app.archive();
await pause(800);
const ticks = [...q("#file-ticks").children],
a = columnFiles(0),
b = columnFiles(1);
app.select(a[1]);
await pause(600);
app.select(b[5]);
await pause(600);
button("column-prev").click();
await pause(90);
check(
moving("#column-name") && moving("#archive-category"),
"切列时底部列名与顶部分类同时滚动",
);
check(
value("#column-name") === archiveColumns[0] &&
value("#archive-category") === archiveColumns[0],
"分类语义文字立即对应当前列",
);
check(
ticks.every((node, i) => q("#file-ticks").children[i] === node),
"切列复用原有八个刻度节点",
);
check(
height(ticks[1]) > 12 &&
height(ticks[1]) < 33 &&
height(ticks[5]) > 12 &&
height(ticks[5]) < 33,
"跨列选中刻度在新旧位置间连续伸缩",
);
check(
app.stats().selected === records[a[1]].id,
"切列保留各自档案位置记忆",
);
button("column-next").click();
await pause(55);
button("column-prev").click();
await pause(650);
check(
ticks[1].getAttribute("aria-pressed") === "true" &&
Math.abs(height(ticks[1]) - 33) < 0.1,
"快速反向切列最终刻度正确",
);
ticks[3].click();
check(
app.stats().selected === records[a[3]].id,
"复用刻度按钮正确映射当前列档案",
);
button("column-next").click();
await pause(650);
check(
app.stats().selected === records[b[5]].id &&
ticks[5].getAttribute("aria-pressed") === "true",
"另一列的选择记忆与刻度一致",
);
check(
parseFloat(
win.getComputedStyle(q("#column-name .rn-face")).fontSize,
) === parseFloat(win.getComputedStyle(q("#column-name")).fontSize),
"列名内部文字保持原字号",
);
check(
parseFloat(
win.getComputedStyle(q("#archive-category .rn-face")).marginLeft,
) === 0,
"分类内部字形没有继承分隔符外边距",
);
for (let i = 0; i < 7; i++) {
button("column-next").click();
await pause(35);
}
await pause(700);
check(
value("#column-name") === archiveColumns[3] &&
value("#archive-category") === archiveColumns[3],
"跨首尾连续切列收敛到最终分类",
);
check(!moving("#selected-clearance"), "内容不变的权限标签不重复滚动");
app.select(a[0]);
await pause(2200);
const canvas = q("#three-scene canvas"),
label = q("#hover-label"),
rect = canvas.getBoundingClientRect(),
hits = [];
function hover(p) {
canvas.dispatchEvent(
new win.PointerEvent("pointermove", {
clientX: rect.left + p.x,
clientY: rect.top + p.y,
bubbles: true,
}),
);
}
for (
let y = rect.height * 0.2;
y < rect.height * 0.8 && hits.length < 3;
y += 22
) {
for (
let x = rect.width * 0.35;
x < rect.width * 0.93 && hits.length < 3;
x += 22
) {
hover({ x, y });
const text = value("#hover-title");
if (!label.hidden && !hits.some((p) => p.text === text))
hits.push({ x, y, text });
}
}
check(hits.length >= 2, "实际场景射线可命中至少两份不同档案");
canvas.dispatchEvent(new win.PointerEvent("pointerleave"));
hover(hits[0]);
check(
!label.hidden &&
!moving("#hover-title") &&
value("#hover-title") === hits[0].text,
"首次悬停直接显示当前标题",
);
await pause(100);
hover(hits[1]);
await pause(70);
check(
moving("#hover-title") && value("#hover-title") === hits[1].text,
"悬停从第一份切到第二份即开始文字滚动",
);
hover(hits[0]);
hover(hits[1]);
await pause(650);
check(
value("#hover-title") === hits[1].text && !moving("#hover-title"),
"快速悬停切换显示最终标题",
);
canvas.dispatchEvent(new win.PointerEvent("pointerleave"));
check(
label.hidden && !moving("#hover-title"),
"离开阵列结束隐藏标签的动画",
);
button("column-next").click();
await pause(60);
app.detail();
check(
!moving("#column-name") && !moving("#archive-category") && label.hidden,
"进入详情结束导航文字动画并隐藏悬停",
);
app.archive();
await pause(500);
button("settings").click();
await pause(350);
const reduced = q('[data-pref="reduced"]'),
before = reduced.checked;
if (!before) reduced.click();
app.select(b[2]);
await pause(40);
const reducedSettled =
!moving("#column-name") &&
!moving("#archive-category") &&
!moving("#selected-title");
if (!before) reduced.click();
check(reducedSettled, "减少动态效果立即更新全部档案文字");
result.textContent = JSON.stringify({ passed: true, checks }, null, 2);
} catch (error) {
result.textContent = JSON.stringify(
{ passed: false, checks, error: error.message },
null,
2,
);
console.error(error);
}
</script>
</html>
+10
View File
@@ -0,0 +1,10 @@
<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>入场比例验证</title><style>body{font:14px system-ui;background:#b6b3ad;margin:10px}header{display:flex;gap:8px;margin-bottom:10px}button{padding:8px}iframe{display:block;border:0;width:960px;height:540px}output{display:block;margin:8px}</style>
<header><button data-size="960,540">16:9</button><button data-size="1050,450">超宽屏</button><button data-size="800,600">4:3</button><button data-size="360,640">竖屏</button><button data-shot="11">身份验证</button><button data-shot="15">圆环</button><button data-shot="20">欢迎</button><button data-shot="25.6">阵列</button><button id="short">播放短入场衔接</button></header><output></output><iframe src="/?scene=archive" title="自适应入场"></iframe>
<script>
const f=document.querySelector('iframe'),o=document.querySelector('output');let timer;
function show(time=15){const w=f.contentWindow;clearTimeout(timer);w.wallpaperPropertyListener.setPaused(false);w.rhine.seek(time);timer=setTimeout(()=>{w.wallpaperPropertyListener.setPaused(true);report()},450)}
function report(){const d=f.contentDocument,s=d.querySelector('#stage'),r=s.getBoundingClientRect();o.textContent=`画布 ${Math.round(r.width)}×${Math.round(r.height)} / 视口 ${f.clientWidth}×${f.clientHeight} · ${s.dataset.layout} · 字幕 ${!!d.querySelector('#cinema-caption')} · ${s.dataset.mode}`}
f.onload=()=>{f.contentWindow.wallpaperPropertyListener.applyUserProperties({desktopmode:{value:'workbench'},music:{value:false},sound:{value:false},reduced:{value:false}});setTimeout(()=>show(),1000)};
document.querySelectorAll('[data-size]').forEach(b=>b.onclick=()=>{const [w,h]=b.dataset.size.split(',');f.style.width=w+'px';f.style.height=h+'px';show()});document.querySelectorAll('[data-shot]').forEach(b=>b.onclick=()=>show(+b.dataset.shot));
document.querySelector('#short').onclick=()=>{clearTimeout(timer);const w=f.contentWindow;w.wallpaperPropertyListener.setPaused(false);w.rhine.seek(24.8);timer=setTimeout(report,3500)};
</script></html>
+19
View File
@@ -0,0 +1,19 @@
<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>律动与接力验证</title>
<style>body{margin:0;font:13px system-ui;background:#e8e5df}header{padding:8px;display:flex;gap:8px;flex-wrap:wrap}button{padding:6px}iframe{width:100%;height:calc(100vh - 92px);border:0}output{padding:5px}</style>
<header><button data-audio="music">模拟音乐频谱</button><button data-audio="silent">模拟静音</button><button data-style="original">保持原样</button><button data-style="music-flat">律动时归齐</button><button data-style="flat">始终归齐</button><button id="hide">隐藏游戏入口</button><button id="show">显示游戏入口</button><button id="pause">暂停/恢复宿主</button><button data-boot="auto">工作台短入场</button><button data-boot="show">完整入场</button><button id="archiveboot">展示模式短入场</button><button id="autotest">验证三次接力与恢复</button><button data-breath="on">开启呼吸</button><button data-breath="off">关闭呼吸</button><button data-rhythm="legacy">原版律动</button><button data-rhythm="wave">A · 左低中中右高</button><button data-rhythm="lift">B · 连续流波</button><output></output><strong id="result"></strong></header>
<iframe src="/?scene=archive" title="音乐律动和小游戏"></iframe>
<script>
const frame=document.querySelector('iframe');let music=false,paused=false;
document.querySelector('#autotest').onclick=()=>{const w=frame.contentWindow,d=w.document;document.querySelector('#result').textContent='验证中';(d.querySelector('.relay-retry:not([hidden])')||d.querySelector('.relay-entry'))?.click();let count=0;const poll=setInterval(()=>{const target=d.querySelector('.relay-target:not([hidden])');if(!target)return;target.click();count++;if(count===3){clearInterval(poll);setTimeout(()=>{const score=d.querySelector('#stage').dataset.relayScore;d.querySelector('.relay-exit').click();setTimeout(()=>document.querySelector('#result').textContent='接力得分 '+score+';退出状态 '+d.querySelector('#stage').dataset.relayStatus,500)},200)}},500)};
const props=p=>frame.contentWindow.wallpaperPropertyListener?.applyUserProperties(Object.fromEntries(Object.entries(p).map(([k,v])=>[k,{value:v}])));
document.querySelectorAll('[data-breath]').forEach(b=>b.onclick=()=>props({idlebreathing:b.dataset.breath==='on'}));
document.querySelectorAll('[data-rhythm]').forEach(b=>b.onclick=()=>props({rhythmstyle:b.dataset.rhythm}));
frame.addEventListener('load',()=>props({desktopmode:'workbench',sound:false,music:false,reduced:false,audioreactive:true,gamepace:'gentle'}));
document.querySelectorAll('[data-audio]').forEach(b=>b.onclick=()=>music=b.dataset.audio==='music');
document.querySelectorAll('[data-style]').forEach(b=>b.onclick=()=>props({selectionstyle:b.dataset.style}));
document.querySelector('#hide').onclick=()=>props({showgame:false});document.querySelector('#show').onclick=()=>props({showgame:true});
document.querySelector('#pause').onclick=()=>frame.contentWindow.wallpaperPropertyListener.setPaused(paused=!paused);
document.querySelectorAll('[data-boot]').forEach(b=>b.onclick=()=>{paused=false;frame.contentWindow.wallpaperPropertyListener.setPaused(false);props({desktopmode:'workbench',openingdetail:b.dataset.boot});frame.contentWindow.rhine.seek(24.8)});
document.querySelector('#archiveboot').onclick=()=>{paused=false;frame.contentWindow.wallpaperPropertyListener.setPaused(false);props({desktopmode:'archive',openingdetail:'skip'});frame.contentWindow.rhine.seek(24.8)};
setInterval(()=>{const w=frame.contentWindow,t=w.performance.now()/1000;w.rhineWallpaperSpectrum={samples:Array.from({length:128},(_,i)=>music?(.3+.24*Math.sin(t*4))*(i%64<8?1:i%64<32?.7:.4):0),time:t};const s=w.document.querySelector('#stage');document.querySelector('output').textContent=s?`呼吸 ${w.rhine?.stats().idleGain?.toFixed(3)} · 状态 ${s.dataset.relayStatus} · 得分 ${s.dataset.relayScore} · 音乐 ${s.dataset.spectrumLevel} · 模式 ${s.dataset.mode} · 暂停 ${w.rhineWallpaperHost?.paused} · 时间 ${w.rhine?.stats().bootTime?.toFixed(2)}`:''},33);
</script></html>
+40
View File
@@ -0,0 +1,40 @@
<!doctype html>
<html lang="zh-CN">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Rhine Lab · 运动与材质对照</title>
<style>
*{box-sizing:border-box}body{margin:0;background:#eae5e1;color:#262824;font:14px Arial,"Microsoft YaHei",sans-serif}main{max-width:1800px;margin:auto;padding:28px}h1{font-size:22px;margin:0 0 10px;font-weight:600}p{line-height:1.7;color:#64645c;margin:0 0 22px}.views{display:grid;grid-template-columns:1fr 1fr;gap:12px}figure{margin:0}figcaption{margin:10px 0;font-size:12px}video,iframe{display:block;width:100%;aspect-ratio:16/9;border:0;background:#eae5e1}nav{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:22px 0 16px}button,a{font:inherit;color:inherit;border:1px solid #bcb9af;background:transparent;padding:9px 14px;cursor:pointer;text-decoration:none}button:hover,a:hover{background:#dcd6cc}button:focus-visible,input:focus-visible,a:focus-visible{outline:2px solid #94714b;outline-offset:3px}input{width:100%;accent-color:#786047}output{font:14px monospace;margin-left:auto}.marks{display:flex;gap:8px;flex-wrap:wrap;margin-top:14px}small{display:block;margin-top:20px;color:#64645c;line-height:1.8}@media(max-width:850px){.views{grid-template-columns:1fr}main{padding:16px}}
.reference-frame{position:relative}.reference-frame img{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;z-index:1;pointer-events:none}.reference-frame.playing img{display:none}</style>
<main>
<h1>运动与材质对照</h1>
<p>左侧为原片,右侧为实时三维场景。拖动时间轴或逐帧前后移动,两侧会同步;原片每秒 25 帧。</p>
<div class="views">
<figure><figcaption>原视频 · 26.00–39.96 秒</figcaption><div class="reference-frame"><img id="reference-still" alt="原视频当前帧" src="motion/0787.jpg"><video id="reference" muted playsinline preload="auto" src="../《明日方舟》特别映像%20[莱茵生命:访问]%20[BV1rr4y1b7sz].mp4"></video></div></figure>
<figure><figcaption>实时场景 · Blender 模型 / Three.js</figcaption><iframe id="replica" title="实时场景对照" src="/?time=21&freeze=1&review=1"></iframe></figure>
</div>
<nav><button id="play">播放对照</button><button id="prev">← 前一帧</button><button id="next">后一帧 →</button><a href="/?time=21.8" target="_blank">播放完整尺寸动画 ↗</a><a href="/?scene=archive" target="_blank">进入交互 ↗</a><output id="stamp">26.00 s · FRAME 650</output></nav>
<input id="timeline" aria-label="原视频帧位置" type="range" min="650" max="999" step="1" value="650">
<div class="marks"><button data-frame="760">760 · 等高波峰</button><button data-frame="772">772 · 镜头移列</button><button data-frame="785">785 · 选中列</button><button data-frame="690">标注① · 27.60</button><button data-frame="729">标注② · 29.16</button><button data-frame="789">标注③ · 31.56</button><button data-frame="670">26.80 · 白场过渡</button><button data-frame="787">31.48 · 镜头对照</button><button data-frame="687">27.48 · 第一轮波峰</button><button data-frame="737">29.48 · 回传波峰</button><button data-frame="780">31.20 · 第一段抽取</button><button data-frame="825">33.00 · 第二段抽取</button><button data-frame="975">39.00 · 材质特写</button></div>
<small>此页仅用于本地复核,产品入口没有播放或嵌入参考视频。两侧仍存在几何、光照与景深差异;时间轴为逐帧观察后的运动重建,没有从原片恢复相机或三维场景数据。</small>
</main>
<script type="module">
const video=document.querySelector('#reference'),frame=document.querySelector('#replica'),slider=document.querySelector('#timeline'),stamp=document.querySelector('#stamp'),play=document.querySelector('#play');
const still=document.querySelector('#reference-still');let desired=31.48;
function sync(t){desired=t;still.src=`motion/${String(Math.round(t*25)).padStart(4,'0')}.jpg`;slider.value=String(Math.round(t*25));stamp.textContent=`${t.toFixed(2)} s · FRAME ${Math.round(t*25)}`;frame.contentWindow.postMessage({type:'rhine-review-frame',time:t-5},location.origin)}
function pause(){video.pause();video.parentElement.classList.remove('playing');play.textContent='播放对照'}
function seek(n){pause();const t=Math.max(650,Math.min(999,n))/25;desired=t;video.currentTime=t}
video.addEventListener('loadedmetadata',()=>seek(Math.round(desired*25)));
frame.addEventListener('load',()=>sync(desired));
slider.addEventListener('input',()=>seek(Number(slider.value)));
document.querySelector('#prev').onclick=()=>seek(Number(slider.value)-1);
document.querySelector('#next').onclick=()=>seek(Number(slider.value)+1);
document.querySelectorAll('[data-frame]').forEach(b=>b.onclick=()=>seek(Number(b.dataset.frame)));
play.onclick=async()=>{if(!video.paused){pause();return}if(video.currentTime>=39.92)seek(650);await video.play();video.parentElement.classList.add('playing');play.textContent='暂停'};
video.addEventListener('seeked',()=>sync(video.currentTime));
function tick(_,metadata){if(!video.paused){if(metadata.mediaTime>=39.96){seek(650)}else sync(metadata.mediaTime)}video.requestVideoFrameCallback(tick)}
video.requestVideoFrameCallback(tick);
document.addEventListener('keydown',e=>{if(e.target===slider)return;if(e.code==='ArrowLeft'){e.preventDefault();seek(Number(slider.value)-1)}if(e.code==='ArrowRight'){e.preventDefault();seek(Number(slider.value)+1)}});
</script>
</html>
Binary file not shown.
+51
View File
@@ -0,0 +1,51 @@
<!doctype html>
<html lang="zh-CN"><meta charset="utf-8"><title>档案切换起伏检查</title>
<style>body{margin:0;background:#e8e5e1;color:#252620;font:13px monospace}#scene{width:1100px;height:620px}pre{white-space:pre-wrap;padding:18px}</style>
<div id="scene"></div><pre id="result">正在记录实际场景…</pre>
<script type="module">
import * as THREE from 'three';
import { ArchiveScene } from '/src/scene.ts';
import { columnFiles } from '/src/data.ts';
import { wrap, selectionCell, sameCell } from '/src/archive-loop.ts';
import { archiveWave, idleWave, settlingWave, columnStrength, selectionWave } from '/src/motion.ts';
const scene=new ArchiveScene(document.querySelector('#scene'),selectionWave,false);
await scene.load();
const draw=scene.composer.render.bind(scene.composer);scene.composer.render=()=>{};
let time=0,index=0;
const step=(n=1)=>{for(let f=0;f<n;f++)scene.update(time+=1/60);};
scene.setMode('archive');scene.select(index);step(300);
const files=columnFiles(2),results=[];
let minPulseContribution=Infinity;
function move(direction,frames=120){
index=files[wrap(files.indexOf(index)+direction,files.length)];
const cell=selectionCell(index,scene.selectedCell,{axis:'row',direction});
const returning=scene.outgoing.find(o=>sameCell(o.cell,cell));
const matrix=new THREE.Matrix4();
const slot=scene.cells.findIndex(c=>sameCell(c,cell));
scene.instances[0].getMatrixAt(slot,matrix);
const start=returning?returning.group.position.y:matrix.elements[13];
scene.select(index,{axis:'row',direction});
const heights=[start];
for(let f=0;f<frames;f++){
step();heights.push(scene.model.position.y);
const {row,lane}=scene.selectedCell;
const base=-4.6+archiveWave(row,lane,scene.scanTime)*scene.scanBlend+
idleWave(row,lane,time)*scene.idleGain+
settlingWave(row-scene.shoulder.value,26.56)*columnStrength(lane,scene.laneFocus.value)+scene.lift.value;
minPulseContribution=Math.min(minPulseContribution,scene.model.position.y-base);
}
let peak=start,maxDrop=0;
for(const y of heights.slice(0,91)){peak=Math.max(peak,y);maxDrop=Math.max(maxDrop,peak-y);}
results.push({direction,frames,start,first:heights[1],min:Math.min(...heights),maxDrop,final:heights.at(-1),samples:heights.filter((_,i)=>i%6===0)});
}
move(1);step(300);move(-1);step(300);
for(let i=0;i<10;i++)move(1,12);
for(let i=0;i<10;i++)move(-1,12);
step(300);
const summary=results.map(({samples,...result})=>result);
const errors=[];
if(minPulseContribution < -1e-8)errors.push('Selection pulse pushes the file below the base field');
if(scene.getStats().returningFiles!==0)errors.push('Returning copies fail to settle');
document.querySelector('#result').textContent=JSON.stringify({passed:!errors.length,errors,minPulseContribution,results:summary,returningFiles:scene.getStats().returningFiles},null,2);
draw();
</script></html>
+164
View File
@@ -0,0 +1,164 @@
<!doctype html>
<html lang="zh-CN">
<meta charset="utf-8" /><title>先抬起后波浪检查</title>
<style>
body {
background: #eae5e1;
color: #303329;
font: 13px monospace;
}
#scene {
width: 960px;
height: 540px;
}
pre {
white-space: pre-wrap;
}
</style>
<div id="scene"></div>
<pre id="result">正在检查动画先后关系…</pre>
<script type="module">
import { ArchiveScene } from "/src/scene.ts";
import { selectionWave } from "/src/motion.ts";
import { columnFiles } from "/src/data.ts";
import { wrap, sameCell } from "/src/archive-loop.ts";
const s = new ArchiveScene(document.querySelector("#scene"), selectionWave, true);
await s.load();
const draw = s.composer.render.bind(s.composer);
s.composer.render = () => {};
let time = 0,
index = 0,
latestPulse = -1;
let trackedHeights = null;
const errors = [],
emissions = [];
const expect = (v, m) => {
if (!v) errors.push(m);
};
const step = (count = 1) => {
for (let f = 0; f < count; f++) {
s.update((time += 1 / 60));
trackedHeights?.push(s.model.position.y);
const pulse = s.pulses.at(-1);
if (pulse && pulse.time !== latestPulse) {
latestPulse = pulse.time;
const old = s.outgoing.filter(
(o) =>
o.cell.lane === s.selectedCell.lane &&
Math.abs(o.cell.row - s.selectedCell.row) <= 4,
);
const margin = old.length
? Math.min(
...old.map((o) => s.model.position.y - o.group.position.y),
)
: null;
emissions.push({
time,
lift: s.lift.value,
margin,
cell: { ...s.selectedCell },
});
expect(s.lift.value >= 0.35, "Wave starts before the new file rises");
expect(
margin === null || margin > 0.015 - 1e-8,
"Wave starts while the previous file is higher",
);
expect(
sameCell(pulse, s.selectedCell),
"An obsolete selection emits a delayed wave",
);
}
}
};
function move(direction) {
const files = columnFiles(s.selectedCell.lane % 5);
index = files[wrap(files.indexOf(index) + direction, files.length)];
s.select(index, { axis: "row", direction });
}
s.setMode("archive");
s.select(0);
step(300);
const initial = emissions.length;
const start = time;
move(1);
trackedHeights = [];
step(12);
expect(
emissions.length === initial,
"Wave starts simultaneously with lift",
);
step(120);
expect(
emissions.length === initial + 1,
"Selection fails to emit one wave",
);
const firstDelay = emissions.at(-1).time - start;
let peak = -Infinity,
maxDrop = 0;
for (const height of trackedHeights) {
peak = Math.max(peak, height);
maxDrop = Math.max(maxDrop, peak - height);
}
trackedHeights = null;
expect(
maxDrop < 0.005,
"The selected file jumps and falls after its initial rise",
);
const rapidStart = emissions.length;
for (let i = 0; i < 12; i++) {
move(1);
step(6);
}
expect(
emissions.length === rapidStart,
"Abandoned rapid selections emitted queued waves",
);
step(180);
expect(
emissions.length === rapidStart + 1,
"Last rapid selection did not emit exactly one wave",
);
const oldCell = { ...s.selectedCell },
oldIndex = index;
move(1);
step(6);
s.select(oldIndex, { cell: oldCell });
index = oldIndex;
step(180);
expect(s.pendingPulse === null, "Rapid reversal never settled");
s.setMode("detail");
step(300);
expect(s.canInspect, "Detail inspection lost clearance");
s.rotation = s.targetRotation = 0.8;
step();
s.setMode("archive");
move(1);
const rotatedStart = emissions.length;
step(20);
expect(
emissions.length === rotatedStart,
"Wave starts before rotated old card aligns",
);
step(300);
expect(
emissions.length === rotatedStart + 1,
"Wave missing after detail return",
);
s.setReduced(true);
move(1);
const reducedStart = emissions.length;
step(180);
expect(emissions.length === reducedStart, "Reduced motion emits a wave");
s.setReduced(false);
move(1);
s.setMode("hidden");
step(120);
expect(s.pendingPulse === null, "Replay leaves a pending wave");
document.querySelector("#result").textContent = JSON.stringify(
{ passed: !errors.length, errors, firstDelay, maxDrop, emissions },
null,
2,
);
draw();
</script>
</html>
+46
View File
@@ -0,0 +1,46 @@
<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>外壳 · 刻线与解密过渡</title>
<style>
*{box-sizing:border-box}body{margin:0;background:#eae5e1;color:#292c28;font:14px Arial,"Microsoft YaHei",sans-serif}main{max-width:1700px;margin:auto;padding:28px}h1{font-size:23px;margin:0 0 12px}p{color:#676962;line-height:1.7}nav{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin:24px 0}select,button,a{padding:9px 12px;border:1px solid #b7b9b0;background:transparent;color:inherit;font:inherit}a{text-decoration:none}label{display:flex;gap:10px;align-items:center}.views{display:grid;grid-template-columns:1fr 1fr;gap:20px}.view{height:440px;position:relative;background:#e1e0d9}canvas{width:100%!important;height:100%!important}h2{font-size:15px;font-weight:500}pre{font:12px monospace;white-space:pre-wrap;color:#666}button[aria-pressed=true]{background:#353b30;color:#f1f0e8}@media(max-width:800px){.views{grid-template-columns:1fr}.view{height:330px}}</style>
<main><h1>外壳 · 刻线与解密过渡</h1><p>检查顶边嵌片、两处螺丝及盖板内刻线。播放或拖动进度,观察清晰区域从顶部向下推进。</p>
<nav><label>显示<select id="display"><option value="clear">装配 · 清晰盖板</option><option value="frosted">装配 · 磨砂盖板</option></select></label><button data-angle="oblique" aria-pressed="true">斜向观察</button><button data-angle="front">正面</button><button data-angle="edge">低角度</button><label>缩放<select id="zoom"><option value="1">完整外壳</option><option value="1.6">顶部 1.6×</option><option value="2.2">右上螺丝 2.2×</option></select></label><a href="/?scene=detail" target="_blank">打开详情体验 ↗</a><label>清晰进度<input id="progress" type="range" min="0" max="1" step="0.01" value="1"></label><button id="play">播放变清晰</button></nav>
<section class="views"><article><h2>打磨前</h2><div class="view" id="before"></div></article><article><h2>外壳打磨版</h2><div class="view" id="after"></div></article></section><pre id="status">正在加载…</pre></main>
<script type="module">
import * as THREE from 'three';
import {GLTFLoader} from 'three/addons/loaders/GLTFLoader.js';
import {ArchiveScene} from '/src/scene.ts';
const $=q=>document.querySelector(q),scenes=[new ArchiveScene($('#before')),new ArchiveScene($('#after'))],groups=[];
let angle='oblique',ready=false;const grids=[];
scenes.forEach(s=>s.composer.render=()=>{});
async function asset(s,path){
const raw=(await new GLTFLoader().loadAsync(path)).scene;raw.updateMatrixWorld(true);const group=new THREE.Group();
raw.traverse(o=>{if(!o.isMesh)return;const m=new THREE.Mesh(o.geometry.clone().applyMatrix4(o.matrixWorld),o.material);m.userData={assemblyPart:o.userData.assemblyPart,surface:o.material.name.replace(/\.\d+$/,'')};group.add(m)});
s.appearance.prepare(group);s.appearance.apply(group,1);s.appearance.setClarity(group,1);const label=s.model.children.find(o=>o.isMesh&&!o.userData.surface)?.clone();if(label){label.material=label.material.clone();label.material.opacity=1;group.add(label)}group.position.y=-1.85;s.scene.add(group);return group;
}
function draw(){
if(!ready)return;const mode=$('#display').value,zoom=Number($('#zoom').value);
scenes.forEach((s,i)=>{
grids[i].visible=mode==='interior';const g=groups[i];for(const mesh of g.children)mesh.visible=mode!=='interior'||['optical-core','optical-lenses'].includes(mesh.userData.assemblyPart);
s.appearance.setClarity(g,Number($('#progress').value));
const points={front:[.2,.15,7.6],oblique:[2.6,4.5,6.6],edge:[3.8,7.5,3.0]};
const target=new THREE.Vector3(zoom>2?2.1:zoom>1?0:.12,zoom>1?1.4:.1,0);
const dir=new THREE.Vector3(...points[angle]);s.camera.position.copy(target).add(dir.multiplyScalar((mode==='interior'?.66:1.03)/zoom));s.camera.near=.1;s.camera.far=120;s.camera.fov=34;
s.camera.aspect=s.container.clientWidth/s.container.clientHeight;s.camera.lookAt(target);s.camera.updateProjectionMatrix();s.camera.updateMatrixWorld(true);
s.renderer.setPixelRatio(Math.min(devicePixelRatio,2));s.renderer.setSize(s.container.clientWidth,s.container.clientHeight,false);
s.renderer.render(s.scene,s.camera);
});
$('#status').textContent='清晰进度 '+Math.round(Number($('#progress').value)*100)+'% · 两份模型使用相同的新版盖板过渡';
}
try{
await Promise.all(scenes.map((s,i)=>s.load(i?'/assets/archive-cassette.glb':'/reference/shell-study/assembly-before.glb')));
for(const s of scenes){s.scene.fog=null;for(const child of s.scene.children)if(child.isMesh||child.isGroup)child.visible=false;}
groups.push(...await Promise.all(scenes.map((s,i)=>asset(s,i?'/assets/archive-assembly.glb':'/reference/shell-study/assembly-before.glb'))));
for(const s of scenes){
const grid=new THREE.GridHelper(8,32,0xaaa99d,0xbab9ae);grid.rotation.x=Math.PI/2;grid.position.z=-.065;s.scene.add(grid);grids.push(grid);
}
ready=true;draw();
}catch(e){$('#status').textContent=String(e);console.error(e)}
document.querySelectorAll('[data-angle]').forEach(b=>b.onclick=()=>{angle=b.dataset.angle;document.querySelectorAll('[data-angle]').forEach(x=>x.setAttribute('aria-pressed',String(x===b)));draw()});
let queued=false;const queueDraw=()=>{if(queued)return;queued=true;requestAnimationFrame(()=>{queued=false;draw()})};
$('#progress').oninput=()=>{$('#display').value=Number($('#progress').value)===0?'frosted':'clear';queueDraw()};$('#display').onchange=()=>{$('#progress').value=$('#display').value==='frosted'?'0':'1';queueDraw()};let playId=0;$('#play').onclick=()=>{cancelAnimationFrame(playId);const start=performance.now();const tick=now=>{$('#progress').value=Math.min(1,(now-start)/2200);draw();if(now-start<2200)playId=requestAnimationFrame(tick)};playId=requestAnimationFrame(tick)};$('#zoom').onchange=queueDraw;addEventListener('resize',queueDraw);
</script></html>
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>石墨灰配色验证</title><style>body{margin:0;font:14px system-ui;background:#a5a5a0}header{padding:8px;display:flex;gap:10px}button{padding:8px}iframe{width:100%;height:calc(100vh - 60px);border:0}</style><header><button data-theme="light">亮色</button><button data-theme="dark">暗色</button><button id="reverse">快速反向切换</button><button id="archive">档案展示</button><button id="workbench">桌面工作台</button><button id="reduced">减少动态效果</button><button id="full">完整动效</button><output></output></header><iframe src="/?scene=archive" title="明暗配色对比"></iframe><script>
const f=document.querySelector('iframe');const props=p=>f.contentWindow.wallpaperPropertyListener?.applyUserProperties(Object.fromEntries(Object.entries(p).map(([k,v])=>[k,{value:v}])));
f.onload=()=>props({desktopmode:'workbench',sound:false,music:false,audioreactive:false,reduced:false,colortheme:'light'});
document.querySelectorAll('[data-theme]').forEach(b=>b.onclick=()=>props({colortheme:b.dataset.theme}));document.querySelector('#reverse').onclick=()=>{props({colortheme:'dark'});setTimeout(()=>props({colortheme:'light'}),350);setTimeout(()=>props({colortheme:'dark'}),620)};
document.querySelector('#archive').onclick=()=>props({desktopmode:'archive'});document.querySelector('#workbench').onclick=()=>props({desktopmode:'workbench'});document.querySelector('#reduced').onclick=()=>props({reduced:true});document.querySelector('#full').onclick=()=>props({reduced:false});
setInterval(()=>{const d=f.contentDocument;document.querySelector('output').textContent=d?`暗色表面 ${d.documentElement.dataset.darkSurface} · ${d.documentElement.style.getPropertyValue('--theme-paper')}`:''},100);
</script></html>
Binary file not shown.
+251
View File
@@ -0,0 +1,251 @@
<!doctype html>
<html lang="zh-CN">
<meta charset="utf-8" />
<title>详情与弹窗过渡检查</title>
<style>
body {
font: 14px system-ui;
background: #e8e5e1;
color: #252820;
padding: 20px;
}
iframe {
width: 1100px;
height: 620px;
border: 0;
}
pre {
white-space: pre-wrap;
}
</style>
<h1>详情与弹窗过渡检查</h1>
<pre id="result">正在检查…</pre>
<iframe src="/?scene=archive" title="真实应用"></iframe>
<script type="module">
const frame = document.querySelector("iframe"),
result = document.querySelector("#result"),
checks = [];
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const check = (condition, name) => {
if (!condition) throw Error(name);
checks.push(name);
};
async function until(fn) {
const start = performance.now();
while (!fn()) {
if (performance.now() - start > 30000) throw Error("等待超时");
await pause(25);
}
}
let previousPrefs, previousSaved;
try {
await until(
() =>
frame.contentWindow.rhine?.stats().loaded &&
!frame.contentDocument.querySelector("#loading"),
);
const win = frame.contentWindow,
doc = frame.contentDocument,
app = win.rhine;
const q = (selector) => doc.querySelector(selector);
previousPrefs = win.localStorage.getItem("rhine-settings");
previousSaved = win.localStorage.getItem("rhine-saved");
const key = (element, value) =>
element.dispatchEvent(
new win.KeyboardEvent("keydown", { key: value, bubbles: true }),
);
const finish = (element) =>
element
.getAnimations({ subtree: true })
.forEach((animation) => animation.finish());
const openSettings = async () => {
q('[data-action="settings"]').click();
await until(() => q('[data-pref="reduced"]'));
};
await openSettings();
let reduced = q('[data-pref="reduced"]');
if (reduced.checked) reduced.click();
q('[data-action="close-modal"]').click();
await until(() => !q(".terminal-modal"));
const opener = q('[data-action="search"]');
opener.focus();
opener.click();
let backdrop = q(".modal-backdrop");
check(
backdrop.dataset.transition === "opening",
"检索弹窗以入场状态打开",
);
finish(backdrop);
await pause(0);
q('[data-action="close-modal"]').click();
check(
backdrop.isConnected && backdrop.dataset.transition === "closing",
"关闭时保留弹窗节点进行淡出",
);
check(
q("#archive-ui").inert && q(".system-nav").inert,
"退出完成前保持背景输入隔离",
);
key(q("#archive-search"), "Escape");
key(q("#archive-search"), "Escape");
finish(backdrop);
await until(() => !q(".terminal-modal"));
check(
doc.activeElement === opener && !q("#archive-ui").inert,
"重复 Esc 后正确释放弹窗并恢复原按钮焦点",
);
opener.click();
backdrop = q(".modal-backdrop");
const intro = backdrop.getAnimations()[0];
intro.pause();
intro.currentTime = 120;
const before = Number(win.getComputedStyle(backdrop).opacity);
q('[data-action="close-modal"]').click();
check(
Math.abs(Number(win.getComputedStyle(backdrop).opacity) - before) <
0.02,
"打开途中关闭从当前透明度接续",
);
finish(backdrop);
await until(() => !q(".terminal-modal"));
opener.click();
finish(q(".modal-backdrop"));
await pause(0);
const wanted = q('[data-result="5"]');
wanted.click();
check(
q(".modal-backdrop").dataset.transition === "closing" &&
app.stats().mode === "archive",
"检索选档先完成弹窗退出",
);
finish(q(".modal-backdrop"));
await until(
() => app.stats().mode === "detail" && app.stats().canInspect,
);
check(
app.stats().selected === "X-006" && !q("#detail-ui").inert,
"弹窗退出后进入所选档案",
);
await until(() => doc.activeElement === q("#detail-content"));
check(true, "详情可读后焦点进入正文");
q('[data-tab="notes"]').click();
const panel = q("#tab-panel"),
content = panel.firstChild;
panel.scrollTop = 80;
const scroll = panel.scrollTop,
bookmark = q('[data-action="bookmark"]');
bookmark.focus();
bookmark.click();
check(
q("#tab-panel") === panel &&
panel.firstChild === content &&
panel.scrollTop === scroll,
"收藏保留正文节点和滚动位置",
);
check(
doc.activeElement === bookmark &&
bookmark.getAttribute("aria-pressed") ===
String(app.stats().saved.includes("X-006")),
"收藏保留按钮焦点并同步按下状态",
);
bookmark.click();
q('[data-tab="history"]').click();
q('[data-tab="overview"]').click();
q('[data-tab="notes"]').click();
check(
panel.getAttribute("aria-labelledby") === "tab-notes" &&
q('[data-tab="notes"]').getAttribute("aria-selected") === "true",
"页签快速反向切换显示最后选择",
);
check(
panel.getAnimations().length <= 1,
"连续页签切换只保留当前内容动画",
);
const detail = q("#detail-ui");
finish(detail);
await pause(0);
q('[data-action="back"]').click();
check(
!detail.hidden &&
detail.inert &&
detail.dataset.transition === "closing",
"详情返回时保留可见退出并禁用旧内容",
);
const shade = Number(
q("#stage").style.getPropertyValue("--detail-shade"),
);
check(shade > 0.9, "返回首帧保留镜头对应的背景遮罩");
const exit = detail.getAnimations()[0];
exit.pause();
exit.currentTime = 90;
const detailBefore = Number(win.getComputedStyle(detail).opacity);
app.detail();
check(
!detail.hidden &&
detail.dataset.transition === "opening" &&
Math.abs(
Number(win.getComputedStyle(detail).opacity) - detailBefore,
) < 0.02,
"详情返回中重新进入从当前透明度衔接",
);
await until(() => app.stats().canInspect);
app.archive();
finish(detail);
await until(() => detail.hidden);
check(
app.stats().mode === "archive" && doc.activeElement === q(".read-file"),
"完整返回后隐藏详情并恢复读取按钮焦点",
);
await until(
() =>
Number(q("#stage").style.getPropertyValue("--detail-shade")) < 0.01,
);
check(true, "背景遮罩随镜头归位连续退回阵列");
await openSettings();
reduced = q('[data-pref="reduced"]');
reduced.click();
q('[data-action="close-modal"]').click();
check(!q(".terminal-modal"), "减少动态效果时弹窗立即退出");
app.detail();
await until(() => app.stats().canInspect);
q('[data-tab="history"]').click();
check(
q("#tab-panel").getAnimations().length === 0,
"减少动态效果时页签不播放动画",
);
app.archive();
check(detail.hidden, "减少动态效果时详情立即隐藏");
await openSettings();
q('[data-pref="reduced"]').click();
q('[data-action="close-modal"]').click();
await until(() => !q(".terminal-modal"));
result.textContent = JSON.stringify(
{ passed: true, count: checks.length, checks },
null,
2,
);
} catch (error) {
result.textContent = JSON.stringify(
{ passed: false, checks, error: error.message },
null,
2,
);
console.error(error);
} finally {
const storage = frame.contentWindow.localStorage;
if (previousPrefs !== undefined)
previousPrefs === null
? storage.removeItem("rhine-settings")
: storage.setItem("rhine-settings", previousPrefs);
if (previousSaved !== undefined)
previousSaved === null
? storage.removeItem("rhine-saved")
: storage.setItem("rhine-saved", previousSaved);
}
</script>
</html>
+55
View File
@@ -0,0 +1,55 @@
<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>360° 导航过渡检查</title>
<style>body{font:15px system-ui;background:#e8e5e1;padding:20px}iframe{width:720px;height:405px;border:0}pre{white-space:pre-wrap}</style>
<h1>360° 导航过渡检查</h1><pre id="result">正在检查…</pre><iframe id="app" src="/?scene=detail"></iframe>
<script type="module">
import * as THREE from '/node_modules/three/build/three.module.js';
import {ViewerCameraMotion} from '/src/viewer-camera.ts';
const result=document.querySelector('#result'),app=document.querySelector('#app'),checks=[];
const check=(name,value)=>{checks.push({name,passed:!!value});if(!value)throw Error(name)};
const wait=async predicate=>{const start=performance.now();while(!predicate()){if(performance.now()-start>30000)throw Error('等待页面超时');await new Promise(r=>setTimeout(r,15))}};
const close=(a,b,epsilon=1e-5)=>Math.abs(a-b)<epsilon;
function rig(){const camera=new THREE.PerspectiveCamera(),goal=new THREE.PerspectiveCamera(),target=new THREE.Vector3();goal.position.set(7.2,3.8,12);const motion=new ViewerCameraMotion(camera);motion.snap(goal,target);return{camera,goal,target,motion}}
try {
const a=rig(),b=rig();for(const r of[a,b]){r.goal.position.multiplyScalar(.6);r.target.set(1,.5,0)}
for(let i=0;i<30;i++)a.motion.update(a.goal,a.target,1/30);for(let i=0;i<120;i++)b.motion.update(b.goal,b.target,1/120);
check('30 fps 与 120 fps 的相机过渡一致',a.camera.position.distanceTo(b.camera.position)<1e-7&&a.motion.focus.distanceTo(b.motion.focus)<1e-7);
const c=rig();const initial=c.camera.position.clone();c.goal.position.set(-6,2,-9);c.target.set(2,1,0);c.motion.update(c.goal,c.target,.1);c.goal.position.copy(initial);c.target.set(0,0,0);const before=c.camera.position.clone();c.motion.reset();
check('复位请求不会瞬间移动显示相机',c.camera.position.distanceTo(before)===0);
let minimum=Infinity;for(let i=0;i<34;i++){c.motion.update(c.goal,c.target,1/60);minimum=Math.min(minimum,c.camera.position.distanceTo(c.motion.focus))}
check('复位沿模型外侧过渡并精确回到默认视角',minimum>5&&c.camera.position.distanceTo(initial)<1e-7&&!c.motion.resetting);
c.goal.position.set(-6,3,8);c.motion.update(c.goal,c.target,.1);c.goal.position.copy(initial);c.motion.reset();c.motion.update(c.goal,c.target,.1);const interrupted=c.camera.position.clone();c.motion.interruptReset(c.goal,c.target);
check('新输入从当前画面打断复位',!c.motion.resetting&&c.goal.position.distanceTo(interrupted)<1e-7&&c.camera.position.distanceTo(interrupted)===0);
c.goal.position.set(0,1,6);c.target.set(.5,0,0);c.motion.update(c.goal,c.target,1/60,true);
check('减少动态效果时相机立即到位',c.camera.position.distanceTo(c.goal.position)<1e-7&&c.motion.focus.distanceTo(c.target)===0);
const seam=rig();seam.goal.position.setFromSpherical(new THREE.Spherical(10,1.2,Math.PI-.05));seam.motion.snap(seam.goal,seam.target);const seamStart=seam.camera.position.clone();seam.goal.position.setFromSpherical(new THREE.Spherical(10,1.2,-Math.PI+.05));seam.motion.update(seam.goal,seam.target,.05);
check('旋转越过正负 180° 时走短路径',seam.camera.position.distanceTo(seamStart)<1);
await wait(()=>app.contentWindow?.rhine&&app.contentDocument.querySelector('#stage')?.dataset.mode==='detail'&&!app.contentDocument.querySelector('#loading'));
const doc=app.contentDocument,win=app.contentWindow;
doc.querySelector('.viewer-open').click();const viewer=doc.querySelector('.model-viewer');
const stats=()=>JSON.parse(viewer.dataset.stats||'{}');await wait(()=>stats().ready);
const canvas=viewer.querySelector('canvas');
const key=key=>canvas.dispatchEvent(new win.KeyboardEvent('keydown',{key,bubbles:true,cancelable:true}));
const wheel=deltaY=>canvas.dispatchEvent(new win.WheelEvent('wheel',{deltaY,deltaMode:0,bubbles:true,cancelable:true}));
const origin=stats();key('ArrowRight');await wait(()=>stats().requestedTarget?.some(v=>Math.abs(v)>.01));
let s=stats();check('方向键平移有中间位置',new THREE.Vector3(...s.targetPosition).length()>0&&new THREE.Vector3(...s.targetPosition).distanceTo(new THREE.Vector3(...s.requestedTarget))>.001);
await wait(()=>new THREE.Vector3(...stats().targetPosition).distanceTo(new THREE.Vector3(...stats().requestedTarget))<.001);
const beforeWheel=stats().distance;wheel(-120);wheel(-120);await wait(()=>stats().requestedDistance<beforeWheel-.01);s=stats();
check('滚轮连续缩放累计目标并平滑追随',s.distance<beforeWheel&&s.distance>s.requestedDistance+.001);
await wait(()=>close(stats().distance,stats().requestedDistance,.001));
const beforeKey=stats().distance;key('+');await wait(()=>stats().requestedDistance<beforeKey-.01);s=stats();
check('加减键缩放也有中间距离',s.distance>s.requestedDistance+.001&&s.distance<beforeKey);
viewer.querySelector('[data-viewer="reset"]').click();await wait(()=>stats().resetting);
check('复位按钮进入过渡而非瞬移',!close(stats().distance,origin.distance,.001));
key('ArrowUp');await wait(()=>!stats().resetting);
check('实际方向键能中断正在复位的视角',stats().requestedTarget.some(v=>Math.abs(v)>.01));
key('Home');await wait(()=>!stats().resetting&&close(stats().distance,origin.distance,.0001));
check('Home 复位精确恢复相机和观察中心',new THREE.Vector3(...stats().cameraPosition).distanceTo(new THREE.Vector3(...origin.cameraPosition))<.0001&&new THREE.Vector3(...stats().targetPosition).length()<.0001);
for(let i=0;i<80;i++)key('ArrowRight');for(let i=0;i<80;i++)key('+');await wait(()=>close(stats().requestedDistance,5));
check('连续输入仍遵守平移与缩放边界',new THREE.Vector3(...stats().requestedTarget).length()<=5.00001&&stats().requestedDistance>=4.99999);
key('Home');await wait(()=>!stats().resetting&&close(stats().distance,origin.distance,.0001));
viewer.querySelector('[data-viewer="close"]').click();await wait(()=>viewer.hidden);
doc.querySelector('.viewer-open').click();await wait(()=>!viewer.hidden&&stats().ready);
check('关闭后重新进入没有残留镜头位移',new THREE.Vector3(...stats().targetPosition).length()<.0001&&close(stats().distance,origin.distance,.0001));
result.textContent=JSON.stringify({passed:true,checks},null,2);
}catch(error){result.textContent=JSON.stringify({passed:false,error:String(error),checks},null,2)}
</script></html>
+23
View File
@@ -0,0 +1,23 @@
<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>曲面 HUD 与全局屏幕效果验证</title>
<style>body{margin:0;background:#dedbd6;font:13px system-ui;display:flex;flex-direction:column;height:100vh}header{display:flex;flex-wrap:wrap;gap:7px;padding:8px;flex-shrink:0}button{padding:5px}label{display:flex;align-items:center;gap:3px}input[type=range]{width:88px}iframe{border:0;width:100%;flex:1;min-height:0}output{display:block;padding:4px 8px;min-height:18px;white-space:pre-wrap}button[aria-pressed=true]{background:#b8b8ae}</style>
<header>
<button data-only="hud">只看曲面视差</button><button data-only="fringe">只看色散</button><button data-only="grain">只看颗粒</button><button data-only="vignette">只看暗角</button><button data-only="all">全部效果</button><button data-only="off">关闭效果</button>
<label><input id="tracking" type="checkbox" checked>鼠标追踪</label><label><input id="frost" type="checkbox">磨砂底</label>
<button id="light">亮色</button><button id="dark">暗色</button><button id="mode">切换档案/工作台</button>
<label>纵深<input id="depth" type="range" min="0" max="100" value="20"></label><label>色散<input id="fringe" type="range" min="0" max="100" value="20"></label><label>颗粒<input id="grain" type="range" min="0" max="100" value="20"></label><label>尺寸<input id="size" type="range" min="0" max="100" value="20"></label><label>暗角<input id="vignette" type="range" min="0" max="100" value="20"></label>
<button data-pointer="left">鼠标左上</button><button data-pointer="center">鼠标居中</button><button data-pointer="right">鼠标右下</button><button id="probe">读取诊断</button>
<label>上边距<input id="margin-top" type="range" min="-300" max="300" value="0"></label><label>下边距<input id="margin-bottom" type="range" min="-300" max="300" value="0"></label><label>左边距<input id="margin-left" type="range" min="-300" max="300" value="0"></label><label>右边距<input id="margin-right" type="range" min="-300" max="300" value="0"></label><button id="inset-probe">读取边距位置</button>
</header><output id="result">等待载入</output><iframe src="/?scene=archive" title="效果预览"></iframe>
<script>
const f=document.querySelector('iframe'),out=document.querySelector('output');let selected='off',workbench=true;
function apply(values){f.contentWindow.wallpaperPropertyListener.applyUserProperties(Object.fromEntries(Object.entries(values).map(([k,v])=>[k,{value:v}])))}
function sync(){const all=selected==='all';apply({hudparallax:all||selected==='hud',hudtracking:document.querySelector('#tracking').checked,huddepth:+document.querySelector('#depth').value,screenfinish:['all','fringe','grain','vignette'].includes(selected),screenfringe:all||selected==='fringe'?+document.querySelector('#fringe').value:0,screengrain:all||selected==='grain'?+document.querySelector('#grain').value:0,screengrainsize:+document.querySelector('#size').value,screenvignette:all||selected==='vignette'?+document.querySelector('#vignette').value:0,uifrost:document.querySelector('#frost').checked,uifroststrength:55});document.querySelectorAll('[data-only]').forEach(b=>b.setAttribute('aria-pressed',String(b.dataset.only===selected)))}
f.onload=()=>{apply({desktopmode:'workbench',boot:false,music:false,sound:false,colortheme:'light'});sync();out.textContent='已载入;可分别检查曲面、全局色散、颗粒和暗角'};
for(const b of document.querySelectorAll('[data-only]'))b.onclick=()=>{selected=b.dataset.only;sync()};
for(const input of document.querySelectorAll('input'))input.oninput=()=>{sync();apply(Object.fromEntries(['top','bottom','left','right'].map(side=>['uimargin'+side,+document.querySelector('#margin-'+side).value])))};
for(const theme of ['light','dark'])document.querySelector('#'+theme).onclick=()=>apply({colortheme:theme});
document.querySelector('#mode').onclick=()=>{workbench=!workbench;apply({desktopmode:workbench?'workbench':'archive'})};
for(const b of document.querySelectorAll('[data-pointer]'))b.onclick=()=>{const w=f.contentWindow,s=w.document.querySelector('#stage'),r=s.getBoundingClientRect(),p={left:[.05,.05],center:[.5,.5],right:[.95,.95]}[b.dataset.pointer];s.dispatchEvent(new w.PointerEvent('pointermove',{clientX:r.left+r.width*p[0],clientY:r.top+r.height*p[1],pointerType:'mouse',bubbles:true}));};
document.querySelector('#probe').onclick=()=>{const w=f.contentWindow,s=w.document.querySelector('#stage'),node=w.document.querySelector('.brand'),m=new w.DOMMatrix(w.getComputedStyle(node).transform),a=m.transformPoint({x:0,y:0}),b=m.transformPoint({x:node.offsetWidth,y:0});out.textContent=JSON.stringify({hud:s.dataset.hudDepth,tracking:s.dataset.hudTracking,globalFilter:w.getComputedStyle(s).filter,brandTopSlope:((b.y/b.w-a.y/a.w)/(b.x/b.w-a.x/a.w)).toFixed(3),brandTransform:w.getComputedStyle(node).transform,canvasTransform:w.getComputedStyle(w.document.querySelector('canvas')).transform,noiseSeed:w.document.querySelector('feTurbulence')?.getAttribute('seed'),frost:s.dataset.uiFrost})};
document.querySelector('#inset-probe').onclick=()=>{const w=f.contentWindow,d=w.document,s=d.querySelector('#stage'),r=s.getBoundingClientRect(),brand=d.querySelector('.brand').getBoundingClientRect(),footer=d.querySelector('.system-footer').getBoundingClientRect(),nav=d.querySelector('.system-nav').getBoundingClientRect(),canvas=d.querySelector('canvas').getBoundingClientRect();out.textContent=JSON.stringify({enabled:s.dataset.uiInsets,brandLeft:+(brand.left-r.left).toFixed(2),brandTop:+(brand.top-r.top).toFixed(2),footerBottom:+(r.bottom-footer.bottom).toFixed(2),navRight:+(r.right-nav.right).toFixed(2),font:w.getComputedStyle(d.querySelector('.brand h1')).fontSize,canvas:[canvas.width,canvas.height],css:['top','right','bottom','left'].map(side=>s.style.getPropertyValue('--ui-'+side))})};
</script></html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

+294
View File
@@ -0,0 +1,294 @@
@import url("/src/fonts.css");
* {
box-sizing: border-box;
}
body {
margin: 0;
background: #eae5e1;
color: #272921;
font:
14px "MiSans",
"Microsoft YaHei",
sans-serif;
}
main {
max-width: 1800px;
margin: auto;
padding: 28px 32px;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
}
h1 {
font-size: 25px;
margin: 6px 0 0;
font-weight: 600;
}
h2 {
font-size: 14px;
font-weight: 600;
margin: 0;
}
.eyebrow {
font-size: 10px;
letter-spacing: 0.14em;
color: #68695f;
}
a {
color: inherit;
text-decoration: none;
border-bottom: 1px solid #96958b;
padding: 8px 0;
}
.intro {
color: #68695f;
line-height: 1.8;
margin: 12px 0 22px;
}
.toolbar {
display: flex;
gap: 24px;
align-items: center;
flex-wrap: wrap;
border-top: 1px solid #c9c4ba;
padding: 14px 0;
}
.toolbar label {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
}
select,
button {
font: inherit;
color: inherit;
background: transparent;
border: 1px solid #bdb9ae;
padding: 8px 12px;
cursor: pointer;
}
button:hover,
select:hover {
background: #dfd9cf;
}
button:disabled {
opacity: 0.45;
cursor: wait;
}
button:focus-visible,
a:focus-visible,
input:focus-visible,
select:focus-visible {
outline: 2px solid #6c7054;
outline-offset: 3px;
}
input {
accent-color: #707a53;
}
.views {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
}
.caption {
display: flex;
justify-content: space-between;
align-items: center;
margin: 6px 0 10px;
gap: 12px;
}
.caption h2 {
display: flex;
align-items: center;
gap: 8px;
}
.caption span {
font-size: 11px;
color: #77776b;
}
i {
display: inline-block;
width: 18px;
height: 3px;
}
.baseline {
background: #a66044;
}
.current {
background: #577356;
}
.view {
position: relative;
aspect-ratio: 16/9;
overflow: hidden;
background: #eae5e1;
border: 1px solid #d4cec2;
}
.scene {
width: 100%;
height: 100%;
pointer-events: none;
}
.scene canvas {
display: block;
width: 100%;
height: 100%;
transform-origin: 34% 30%;
}
.view.zoomed canvas {
transform: scale(1.7);
}
.marker {
position: absolute;
width: 14px;
height: 14px;
border-left: 2px solid #6e725e;
border-top: 2px solid #6e725e;
pointer-events: none;
}
.marker span {
position: absolute;
font-size: 10px;
white-space: nowrap;
top: -20px;
left: 0;
padding: 2px 4px;
background: #eae5e1c9;
color: #575e49;
}
.transport {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-top: 18px;
}
.transport button {
font-size: 12px;
}
#play {
background: #353b30;
color: #f4f0e9;
border-color: #353b30;
min-width: 98px;
}
output {
margin-left: auto;
font:
12px ui-monospace,
monospace;
color: #68695f;
}
#timeline {
width: 100%;
margin: 16px 0 8px;
cursor: pointer;
}
.readout {
display: flex;
gap: 32px;
align-items: center;
min-height: 80px;
padding: 15px 0;
border-bottom: 1px solid #c9c4ba;
}
.readout > div {
min-width: 240px;
}
strong {
display: block;
font:
24px ui-monospace,
monospace;
margin-top: 6px;
font-weight: 400;
}
.readout p {
color: #64685b;
font-size: 12px;
line-height: 1.8;
margin: 0;
}
.chart {
margin-top: 18px;
}
.chart-heading {
display: flex;
justify-content: space-between;
gap: 20px;
align-items: center;
}
.chart-heading span,
.chart-note {
font-size: 11px;
color: #737469;
}
svg {
width: 100%;
display: block;
max-height: 200px;
margin-top: 4px;
}
.chart-note {
margin: 0 0 20px;
}
footer {
font-size: 10px;
color: #828176;
line-height: 1.8;
display: flex;
justify-content: space-between;
gap: 14px;
}
#check {
white-space: nowrap;
}
@media (min-width: 1600px) {
main {
padding-top: 36px;
}
.view {
aspect-ratio: 16/10;
}
}
@media (max-width: 760px) {
main {
padding: 20px 16px;
}
.views {
grid-template-columns: 1fr;
}
.toolbar {
gap: 12px;
}
.readout {
gap: 12px;
align-items: flex-start;
flex-direction: column;
}
.chart-heading {
align-items: flex-start;
flex-direction: column;
gap: 6px;
}
header {
align-items: flex-start;
}
header a {
font-size: 11px;
}
h1 {
font-size: 23px;
}
footer {
display: block;
}
.transport output {
width: 100%;
margin-top: 6px;
}
}
+125
View File
@@ -0,0 +1,125 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Rhine Lab · 切换波浪对比</title>
<link rel="icon" href="/favicon.svg" />
<link rel="stylesheet" href="/reference/wave-compare.css" />
</head>
<body>
<main>
<header>
<div>
<span class="eyebrow">RHINE LAB / MOTION STUDY</span>
<h1>切换波浪对比</h1>
</div>
<a href="/?scene=archive" target="_blank" rel="noopener"
>打开当前交互 ↗</a
>
</header>
<p class="intro">
当前主界面已恢复原始基线。左侧选中与波浪同时开始;右侧保留先抬起后波浪的实验方案,便于回看。
</p>
<section class="toolbar" aria-label="对比设置">
<label
>动作
<select id="scenario">
<option value="next">向后切换一次 ↓</option>
<option value="previous">向前切换一次 ↑</option>
<option value="repeat">连续向后切换四次</option>
<option value="reverse">向后,再向前</option>
</select></label
>
<label
>左侧
<select id="baseline">
<option value="original">当前采用 · 原始基线</option>
<option value="previous">实验 · 去掉负波谷</option>
</select></label
>
<label
>速度
<select id="speed">
<option value="1">正常 · 1×</option>
<option value="0.5">慢放 · 0.5×</option>
<option value="0.25" selected>慢放 · 0.25×</option>
</select></label
>
<label class="check"
><input type="checkbox" id="zoom" />放大选中区域</label
>
<label class="check"
><input type="checkbox" id="loop" checked />循环播放</label
>
</section>
<section class="views" aria-label="同步场景">
<article>
<div class="caption">
<h2><i class="baseline"></i>同时播放</h2>
<span id="before-phase">就位</span>
</div>
<div class="view" id="before">
<div class="scene"></div>
<div class="marker" aria-hidden="true"><span>选中档案</span></div>
</div>
</article>
<article>
<div class="caption">
<h2><i class="current"></i>实验 · 先抬起,再波动</h2>
<span id="after-phase">就位</span>
</div>
<div class="view" id="after">
<div class="scene"></div>
<div class="marker" aria-hidden="true"><span>选中档案</span></div>
</div>
</article>
</section>
<section class="transport" aria-label="播放控制">
<button id="play" disabled>正在准备…</button
><button id="restart" disabled>从头播放</button
><button id="prev" disabled aria-label="前一帧">← 前一帧</button
><button id="next" disabled aria-label="后一帧">后一帧 →</button
><button id="peak" disabled>跳到最大差异</button
><output id="stamp">0.00 / 3.20 秒</output>
</section>
<input
id="timeline"
type="range"
min="0"
max="192"
step="1"
value="0"
aria-label="同步动画时间轴"
disabled
/>
<section class="readout" aria-label="差异说明">
<div>
<span class="eyebrow">当前选中档案 · 高度差</span
><strong id="difference">—</strong>
</div>
<p id="explanation" role="status">正在加载两份相同的三维场景…</p>
</section>
<section class="chart">
<div class="chart-heading">
<h2>选中档案的实际高度</h2>
<span>横轴为切换后的时间;纵轴为相对起始高度(场景单位)</span>
</div>
<svg
id="chart"
viewBox="0 0 1120 190"
role="img"
aria-label="基线与实验版本的档案高度曲线"
></svg>
<p class="chart-note">
虚线标记切换时刻。连续切换时,曲线会接续新选中档案的高度。
</p>
</section>
<footer>
原始基线 aeddd8f · 上一版 2048536 ·
两侧共用相同模型、材质、相机与固定时间步长。<span id="check"></span>
</footer>
</main>
<script type="module" src="/reference/wave-compare.js"></script>
</body>
</html>
+355
View File
@@ -0,0 +1,355 @@
import { ArchiveScene } from "/src/scene.ts";
import { selectionWave as baselineWave } from "./baseline-motion.ts";
import { selectionWave as currentWave } from "/src/motion.ts";
import { columnFiles } from "/src/data.ts";
import { wrap } from "/src/archive-loop.ts";
const $ = (s) => document.querySelector(s),
duration = 192,
dt = 1 / 60,
startTime = 8;
const scenes = [
new ArchiveScene($("#before .scene"), currentWave, false),
new ArchiveScene($("#after .scene"), currentWave, true),
];
const draws = scenes.map((s) => s.composer.render.bind(s.composer));
// Seek by simulating at 60 Hz; only the requested frame is drawn.
scenes.forEach((s) => {
s.composer.render = () => {};
});
const fields = [
"lift",
"rail",
"shoulder",
"laneFocus",
"columnCamera",
"selectedCell",
"coordinateOrigin",
"pulses",
"pendingPulse",
"rotation",
"targetRotation",
"returnY",
"detail",
"targetDetail",
"reveal",
"targetReveal",
"last",
"clock",
"idleGain",
"pulseGain",
"lastInteraction",
"scanTime",
"scanBlend",
"selectedSlot",
"looping",
"canInspect",
"clearance",
];
let saved = [],
frame = 0,
index = 0,
events = [],
samples = [],
maxFrame = 0,
playing = false,
ready = false,
lastWall = 0,
accumulator = 0;
const files = columnFiles(2);
const schedules = {
next: [[1, 1]],
previous: [[1, -1]],
repeat: [
[1, 1],
[19, 1],
[37, 1],
[55, 1],
],
reverse: [
[1, 1],
[25, -1],
],
};
function snapshot(s) {
return {
state: Object.fromEntries(fields.map((k) => [k, structuredClone(s[k])])),
camera: s.camera.position.clone(),
aim: s.cameraAim.clone(),
fov: s.camera.fov,
};
}
function reset() {
scenes.forEach((s, i) => {
s.setMode("hidden");
for (const key of fields) s[key] = structuredClone(saved[i].state[key]);
s.camera.position.copy(saved[i].camera);
s.cameraAim.copy(saved[i].aim);
s.camera.fov = saved[i].fov;
s.camera.lookAt(s.cameraAim);
s.camera.updateProjectionMatrix();
s.camera.updateMatrixWorld();
s.drawLabel(0);
s.pointer.set(0, 0);
});
frame = 0;
index = 0;
}
function advance() {
frame++;
const action = events.find(([f]) => f === frame);
if (action) {
const direction = action[1];
index = files[wrap(files.indexOf(index) + direction, files.length)];
scenes.forEach((s) => s.select(index, { axis: "row", direction }));
}
scenes.forEach((s) => s.update(startTime + frame * dt));
}
function setPlaying(value) {
playing = value;
accumulator = 0;
lastWall = 0;
$("#play").textContent = value ? "暂停" : "同步播放";
}
function seek(target) {
target = Math.max(0, Math.min(duration, target));
if (target <= frame) reset();
while (frame < target) advance();
// The first frame is the checkpoint, prior to the first input.
if (target === 0) {
scenes.forEach((s) => s.update(startTime));
reset();
}
render();
}
function render() {
scenes.forEach((s, i) => {
draws[i]();
const [x, y] = s.projectCard(-2.5, 3.7),
view = $(["#before", "#after"][i]);
const zoom = $("#zoom").checked ? 1.7 : 1;
const px = (x / 1920 - 0.34) * zoom + 0.34,
py = (y / 1080 - 0.3) * zoom + 0.3;
const marker = view.querySelector(".marker");
marker.style.left = `${px * 100}%`;
marker.style.top = `${py * 100}%`;
$(["#before-phase", "#after-phase"][i]).textContent = s.pendingPulse
? "档案抬起中"
: s.pulses.length
? "波浪传播中"
: "就位";
});
$("#timeline").value = String(frame);
$("#stamp").textContent =
`${(frame * dt).toFixed(2)} / ${(duration * dt).toFixed(2)} 秒`;
const delta = scenes[1].model.position.y - scenes[0].model.position.y;
const replayError = Math.max(
Math.abs(scenes[0].model.position.y - samples[frame].before),
Math.abs(scenes[1].model.position.y - samples[frame].after),
);
$("#check").textContent =
replayError < 1e-7 ? "同步回放校验通过" : "回放校验异常";
$("#check").dataset.replayError = String(replayError);
$("#difference").textContent =
`${delta >= 0 ? "+" : ""}${delta.toFixed(3)} · ${((Math.abs(delta) / 3.7) * 100).toFixed(1)}%`;
const max = samples[maxFrame].after - samples[maxFrame].before;
$("#explanation").textContent =
Math.abs(delta) < 0.001
? `此刻两版几乎重合。最大高度差出现在 ${(maxFrame * dt).toFixed(2)} 秒,为卡片高度的 ${((Math.abs(max) / 3.7) * 100).toFixed(1)}%。`
: `右侧选中档案比左侧${delta >= 0 ? "高" : "低"} ${Math.abs(delta).toFixed(3)} 个场景单位,占卡片高度的 ${((Math.abs(delta) / 3.7) * 100).toFixed(1)}%。观察右侧先抬起档案,再带动周围起伏的顺序。`;
const x = 56 + (frame / duration) * 1044;
$("#cursor").setAttribute("x1", x);
$("#cursor").setAttribute("x2", x);
}
function chart() {
// The zero checkpoint still owns the old file. Plot from the first input so
// changing ownership is not misrepresented as a downward motion of one file.
const origin = samples[1].before,
values = samples
.slice(1)
.flatMap((p) => [p.before - origin, p.after - origin]);
const min = Math.floor((Math.min(...values) - 0.03) * 10) / 10,
max = Math.ceil((Math.max(...values) + 0.03) * 10) / 10;
const x = (f) => 56 + (f / duration) * 1044,
y = (v) => 154 - ((v - origin - min) / (max - min)) * 130;
const path = (key) =>
samples
.slice(1)
.map(
(p, f) =>
`${f ? "L" : "M"}${x(f + 1).toFixed(2)},${y(p[key]).toFixed(2)}`,
)
.join(" ");
let markup = "";
for (let i = 0; i <= 4; i++) {
const v = min + ((max - min) * i) / 4,
py = y(v + origin);
markup += `<path d="M56 ${py}H1100" stroke="#cfcabe" stroke-width=".6"/><text x="44" y="${py + 4}" text-anchor="end" fill="#77796b" font-size="11">${v.toFixed(2)}</text>`;
}
for (let i = 0; i <= duration; i += 30)
markup += `<text x="${x(i)}" y="180" text-anchor="middle" fill="#77796b" font-size="11">${(i * dt).toFixed(1)}s</text>`;
for (const [f] of events)
markup += `<path d="M${x(f)} 18V154" stroke="#9d9c8e" stroke-dasharray="3 5"/>`;
markup += `<path d="${path("before")}" fill="none" stroke="#a66044" stroke-width="2.3"/><path d="${path("after")}" fill="none" stroke="#577356" stroke-width="2.3"/><line id="cursor" x1="56" x2="56" y1="16" y2="155" stroke="#353b30"/>`;
$("#chart").innerHTML = markup;
}
function prepare() {
setPlaying(false);
events = schedules[$("#scenario").value];
scenes[0].selectionPulse =
$("#baseline").value === "original" ? baselineWave : currentWave;
reset();
// The checkpoint has already been rendered into the selected model.
scenes.forEach((s) => s.update(startTime));
reset();
samples = [
{ before: scenes[0].model.position.y, after: scenes[1].model.position.y },
];
while (frame < duration) {
advance();
samples.push({
before: scenes[0].model.position.y,
after: scenes[1].model.position.y,
});
}
maxFrame = samples.reduce(
(best, p, f) =>
Math.abs(p.after - p.before) >
Math.abs(samples[best].after - samples[best].before)
? f
: best,
0,
);
chart();
seek(0);
}
$("#play").onclick = () => {
if (frame === duration) seek(0);
setPlaying(!playing);
};
$("#restart").onclick = () => {
seek(0);
setPlaying(true);
};
$("#prev").onclick = () => {
setPlaying(false);
seek(frame - 1);
};
$("#next").onclick = () => {
setPlaying(false);
seek(frame + 1);
};
$("#peak").onclick = () => {
setPlaying(false);
seek(maxFrame);
};
$("#timeline").oninput = (e) => {
setPlaying(false);
seek(Number(e.target.value));
};
$("#scenario").onchange = () => {
if (ready) {
prepare();
setPlaying(true);
}
};
$("#baseline").onchange = () => {
if (ready) {
prepare();
setPlaying(true);
}
};
$("#zoom").onchange = () => {
document
.querySelectorAll(".view")
.forEach((v) => v.classList.toggle("zoomed", $("#zoom").checked));
if (ready) render();
};
$("#chart").onclick = (e) => {
if (!ready) return;
const rect = $("#chart").getBoundingClientRect();
setPlaying(false);
seek(
Math.round(
((((e.clientX - rect.left) / rect.width) * 1120 - 56) / 1044) * duration,
),
);
};
function resize() {
scenes.forEach((s) => {
s.resize();
// These are real-size comparison panels, not the scaled 1920px app stage.
const ratio = Math.min(devicePixelRatio, 1.5);
s.renderer.setPixelRatio(ratio);
s.composer.setPixelRatio(ratio);
});
if (ready) render();
}
addEventListener("resize", resize);
document.addEventListener("keydown", (e) => {
if (!ready || ["INPUT", "SELECT", "BUTTON", "A"].includes(e.target.tagName))
return;
if (e.code === "Space") {
e.preventDefault();
$("#play").click();
}
if (e.code === "ArrowLeft" || e.code === "ArrowRight") {
e.preventDefault();
setPlaying(false);
seek(frame + (e.code === "ArrowLeft" ? -1 : 1));
}
});
function tick(wall) {
if (playing && ready && !document.hidden) {
if (lastWall)
accumulator +=
Math.min((wall - lastWall) / 1000, 0.1) * Number($("#speed").value);
const count = Math.floor(accumulator / dt);
if (count) {
accumulator -= count * dt;
let target = frame + count;
if (target > duration) {
if ($("#loop").checked) {
reset();
target %= duration;
} else {
target = duration;
setPlaying(false);
}
}
seek(target);
}
}
lastWall = wall;
requestAnimationFrame(tick);
}
try {
await document.fonts.ready;
await Promise.all(scenes.map((s) => s.load()));
scenes.forEach((s) => {
s.setMode("archive");
s.select(0);
for (let f = 1; f <= 480; f++) s.update(f * dt);
});
// Start both runs with the exact same resting state and no residual pulses.
scenes.forEach((s) => {
s.pulses = [];
s.idleGain = 0;
s.lastInteraction = startTime;
s.update(startTime);
});
saved = scenes.map(snapshot);
resize();
ready = true;
document
.querySelectorAll(".transport button,#timeline")
.forEach((el) => (el.disabled = false));
prepare();
setPlaying(true);
requestAnimationFrame(tick);
} catch (error) {
$("#explanation").textContent = `场景加载失败:${error.message}`;
$("#play").textContent = "加载失败";
console.error(error);
}
+11
View File
@@ -0,0 +1,11 @@
<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>工作台宿主回调验证</title>
<style>body{margin:0;font:14px system-ui;background:#e8e5df}header{padding:10px;display:flex;gap:12px;flex-wrap:wrap}input{width:140px}button{padding:6px}iframe{width:100%;height:calc(100vh - 95px);border:0}</style>
<header><label>事项 1 <input id="task" value="测试事项"></label><label>目标日期 <input id="date" value="2026-12-31 18:00"></label><button id="apply">应用测试属性</button><button id="reload">重载验证保存</button><button id="media">模拟媒体回调</button><span>仅验证页使用模拟数据;不随壁纸发布。</span></header>
<iframe src="/?scene=archive" title="工作台"></iframe>
<script>
const frame=document.querySelector('iframe');
const presets=document.createElement('div'); presets.innerHTML='<button data-display="quiet">模拟 WE:全部隐藏</button><button data-display="clock">模拟 WE:只留时钟</button><button data-display="full">模拟 WE:恢复显示</button>';document.querySelector('header').append(presets);
presets.addEventListener('click',event=>{const p=event.target.dataset.display;if(!p)return;const properties={};for(const key of ['clock','tasks','module','navigation','brand','footer','settings'])properties['show'+key]={value:p==='full'||(p==='clock'&&key==='clock')};frame.contentWindow.wallpaperPropertyListener.applyUserProperties(properties)});
function apply(){const w=frame.contentWindow;if(!w.wallpaperPropertyListener)return;w.wallpaperPropertyListener.applyUserProperties({desktopmode:{value:'workbench'},boot:{value:false},music:{value:false},sound:{value:false},task1:{value:document.querySelector('#task').value},eventname:{value:'测试目标'},eventdate:{value:document.querySelector('#date').value},focusminutes:{value:1}})}
frame.addEventListener('load',apply);document.querySelector('#apply').onclick=apply;document.querySelector('#reload').onclick=()=>frame.contentWindow.location.reload();document.querySelector('#media').onclick=()=>{const w=frame.contentWindow;w.rhineWallpaperMedia={status:{enabled:true},properties:{title:'媒体接口测试',artist:'测试歌手'},timeline:{position:30,duration:180},playing:true};w.dispatchEvent(new Event('rhine-wallpaper-media'))};
</script></html>
+59
View File
@@ -0,0 +1,59 @@
<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>工作台切换与开场复核</title>
<style>body{margin:0;background:#969994;font:13px system-ui}header{padding:8px;display:flex;gap:8px;flex-wrap:wrap}button{padding:8px}#view{position:relative;margin:auto;overflow:hidden}iframe{display:block;border:0;transform-origin:top left}output{display:block;padding:8px;white-space:pre-wrap;max-height:180px;overflow:auto}</style>
<header><button id="test">运行界面回归</button><button id="gametest">验证游戏进出过渡</button><button data-mode="archive">档案展示</button><button data-mode="workbench">工作台</button><button data-size="1920,1080">16:9</button><button data-size="3840,1080">32:9</button><button data-size="1080,1920">竖屏</button><button id="opening">播放阵列开场</button><button id="freeze">暂停/恢复</button><button id="game">开始接力</button><button data-rhythm="legacy">原版律动</button><button data-rhythm="wave">A · 左低中中右高</button><button data-rhythm="lift">B · 连续流波</button><button data-band="low">持续低频</button><button data-band="mid">持续中频</button><button data-band="high">持续高频</button><button data-band="all">混合频谱</button><button data-band="silent">静音</button></header>
<div id="view"><iframe src="/?scene=archive" title="工作台修订预览"></iframe></div><output id="status"></output><output id="result"></output>
<script>
const f=document.querySelector('iframe'),view=document.querySelector('#view'),result=document.querySelector('#result');let size=[1920,1080],paused=false,band='silent';
const wait=ms=>new Promise(r=>setTimeout(r,ms)),w=()=>f.contentWindow,d=()=>f.contentDocument;
const props=p=>w().wallpaperPropertyListener?.applyUserProperties(Object.fromEntries(Object.entries(p).map(([k,v])=>[k,{value:v}])));
function fit(){const scale=Math.min((innerWidth-20)/size[0],(innerHeight-260)/size[1]);f.style.width=size[0]+'px';f.style.height=size[1]+'px';f.style.transform=`scale(${scale})`;view.style.width=size[0]*scale+'px';view.style.height=size[1]*scale+'px'}fit();window.onresize=fit;
const until=async fn=>{for(let i=0;i<50&&!fn();i++)await wait(100)};
const assert=(ok,label)=>{result.textContent+=(ok?'PASS ':'FAIL ')+label+'\n';if(!ok)throw Error(label)};
f.onload=async()=>{while(!w().rhine?.stats().ready)await wait(100);props({desktopmode:'workbench',sound:false,music:false,reduced:false,colortheme:'dark',showgame:true,audioreactive:true,selectionstyle:'flat',idlebreathing:false});w().rhine.archive()};
document.querySelectorAll('[data-mode]').forEach(b=>b.onclick=()=>props({desktopmode:b.dataset.mode}));
document.querySelectorAll('[data-size]').forEach(b=>b.onclick=()=>{size=b.dataset.size.split(',').map(Number);fit()});
document.querySelectorAll('[data-rhythm]').forEach(b=>b.onclick=()=>props({rhythmstyle:b.dataset.rhythm}));
document.querySelectorAll('[data-band]').forEach(b=>b.onclick=()=>band=b.dataset.band);
document.querySelector('#opening').onclick=()=>{paused=false;w().wallpaperPropertyListener.setPaused(false);props({desktopmode:'workbench',openingdetail:'auto'});w().rhine.seek(22)};
document.querySelector('#freeze').onclick=()=>w().wallpaperPropertyListener.setPaused(paused=!paused);
document.querySelector('#game').onclick=()=>d().querySelector('.relay-entry').click();
setInterval(()=>{if(!w().rhine)return;const t=w().performance.now()/1000;w().rhineWallpaperSpectrum={samples:Array.from({length:128},(_,i)=>{const key=i%64<8?'low':i%64<32?'mid':'high';return band===key?.45:band==='all'?.25+.18*Math.sin(t*(key==='low'?2:key==='mid'?3:5)):0}),time:t};const s=w().rhine.stats();document.querySelector('#status').textContent=JSON.stringify({size,mode:s.mode,time:s.bootTime,drawn:s.archiveCount,candidates:s.archiveCandidates,layout:d().querySelector('#stage')?.dataset.layout,band})},100);
document.querySelector('#test').onclick=async()=>{result.textContent='';try{
props({desktopmode:'workbench',reduced:false,colortheme:'dark'});w().rhine.archive();await wait(1000);
assert(!d().querySelector('.wb-content').textContent.includes('跟随本机'),'移除时区说明');
w().rhineWallpaperMedia={status:{enabled:true},properties:{title:'回归测试曲目',artist:'测试'},playing:true};w().dispatchEvent(new (w().Event)('rhine-wallpaper-media'));d().querySelector('[data-wb-lane="3"]').click();
assert(!d().querySelector('.wb-content').textContent.includes('未提供进度'),'无进度时不显示说明');
const tick=w().getComputedStyle(d().querySelector('.wb-nav [aria-pressed="true"]'),'::before').backgroundColor;
assert(tick!=='rgb(8, 10, 8)'&&tick!=='rgba(0, 0, 0, 0)','暗色刻度 '+tick);
const root=d().querySelector('.workbench');props({desktopmode:'archive'});
assert(!root.hidden&&root.inert&&root.getAnimations().length>0,'工作台退场保留节点并隔离输入');await until(()=>root.hidden);assert(root.hidden,'工作台退场后隐藏');
await wait(400);const archive=d().querySelector('#archive-ui');props({desktopmode:'workbench'});
assert(w().getComputedStyle(archive).visibility==='visible'&&archive.inert,'档案退场保留可见性并隔离输入');await until(()=>w().getComputedStyle(archive).visibility==='hidden');assert(w().getComputedStyle(archive).visibility==='hidden','档案退场后隐藏');
props({desktopmode:'archive'});await wait(55);props({desktopmode:'workbench'});await until(()=>!root.hidden&&Number(w().getComputedStyle(root).opacity)>.99&&!root.inert);assert(!root.hidden&&Number(w().getComputedStyle(root).opacity)>.99&&!root.inert,'快速反向切换正确恢复');
d().querySelector('.relay-entry').click();await wait(300);const a=d().querySelector('.relay-exit').getBoundingClientRect(),b=d().querySelector('.settings-button').getBoundingClientRect();assert(a.top>=b.bottom+8,'结束游戏与设置分离');d().querySelector('.relay-exit').click();
props({reduced:true,desktopmode:'archive'});assert(root.hidden,'减少动态效果直接隐藏');props({desktopmode:'workbench'});assert(!root.hidden,'减少动态效果直接显示');props({reduced:false,openingdetail:'auto'});
size=[3840,1080];fit();w().wallpaperPropertyListener.setPaused(false);let hold=setInterval(()=>w().rhine.seek(24),20);await until(()=>d().querySelector('#stage').dataset.boot==='array'&&w().rhine.stats().archiveCount>300);clearInterval(hold);w().wallpaperPropertyListener.setPaused(true);
assert(w().rhine.stats().mode==='boot'&&d().querySelector('#stage').dataset.layout==='opening','普通开场使用实际视口');
assert(w().rhine.stats().archiveCount>159,'超宽开场已动态补齐 '+w().rhine.stats().archiveCount);
assert(w().getComputedStyle(archive).visibility==='hidden','工作台开场隐藏选档标签');
w().wallpaperPropertyListener.setPaused(false);hold=setInterval(()=>w().rhine.seek(25.65),20);await until(()=>{const p=Number(d().querySelector('.powered').style.opacity);return p>0&&p<1});clearInterval(hold);w().wallpaperPropertyListener.setPaused(true);
const power=Number(w().getComputedStyle(d().querySelector('.powered')).opacity);assert(power>0&&power<1,'Powered 开场末端淡出 '+power.toFixed(3));
w().wallpaperPropertyListener.setPaused(false);await until(()=>w().rhine.stats().mode==='archive');assert(w().rhine.stats().mode==='archive','短开场正常进入工作台');
result.textContent+='DONE';
}catch(e){result.textContent+='ERROR '+e.message}}
document.querySelector('#gametest').onclick=async()=>{result.textContent='';try {
props({desktopmode:'workbench',showgame:true,reduced:false});w().wallpaperPropertyListener.setPaused(false);w().rhine.archive();await wait(700);
const root=d().querySelector('.workbench'),hud=d().querySelector('.relay-hud'),entry=d().querySelector('.relay-entry'),exit=d().querySelector('.relay-exit');
entry.click();assert(hud.dataset.transition==='opening'&&!hud.hidden&&root.inert,'进入游戏:HUD 淡入、旧界面立即隔离');
await until(()=>hud.dataset.transition==='open'&&w().getComputedStyle(root).visibility==='hidden');assert(w().getComputedStyle(root).visibility==='hidden','工作台完成淡出');
exit.click();assert(hud.dataset.transition==='closing'&&!hud.hidden&&hud.inert&&root.inert,'退出游戏:保留 HUD 淡出、隔离点击');
await until(()=>hud.hidden&&!root.inert);assert(hud.hidden&&!root.inert&&w().getComputedStyle(root).visibility==='visible','退出完成恢复工作台');
assert(d().activeElement===entry,'退出后焦点返回入口');
entry.click();exit.click();entry.click();await until(()=>hud.dataset.transition==='open');assert(!hud.hidden&&d().querySelector('#stage').dataset.relay==='true','快速进出再进入不被旧回调隐藏');
size=[1080,1920];fit();await wait(300);const a=exit.getBoundingClientRect(),b=d().querySelector('.settings-button').getBoundingClientRect();assert(a.top>b.bottom,'竖屏退出按钮与设置不重叠');
props({showgame:false});exit.click();await until(()=>hud.hidden);assert(entry.hidden,'隐藏入口时退出不重现入口');
props({showgame:true,reduced:true});await wait(150);entry.click();assert(hud.dataset.transition==='open','减少动态效果直接进入');exit.click();assert(hud.hidden&&!root.inert,'减少动态效果直接退出');
props({reduced:false});entry.click();exit.click();props({desktopmode:'archive'});await until(()=>hud.hidden&&!d().querySelector('#archive-ui').inert);assert(!d().querySelector('#archive-ui').inert,'退出期间切到档案模式不残留输入锁');
props({desktopmode:'workbench'});size=[1920,1080];fit();result.textContent+='DONE';
}catch(e){result.textContent+='ERROR '+e.message}};
</script></html>