Initial commit: RHINE LAB · ANALYSIS OS:三维界面与动效实验(本仓库不含个人归档索引数据)
This commit is contained in:
@@ -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 的调查资料及活动页用于补充沿革和事件关联。正文采用摘要与独立分析,没有复制长段原文。
|
||||
|
||||
@@ -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')
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
@@ -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)
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 |
@@ -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)
|
||||
@@ -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')
|
||||
@@ -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)
|
||||
@@ -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.
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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]])
|
||||
@@ -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])
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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.
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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.
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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.
@@ -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.
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 |
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user