Initial commit: RHINE LAB · ANALYSIS OS:三维界面与动效实验(本仓库不含个人归档索引数据)
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
// Read the local source only; emit measured levels, not a subjective listening report.
|
||||
// node scripts/analyze-reference-audio.mjs path/to/ffmpeg.exe
|
||||
import fs from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
const video = fs.readdirSync(".").find((x) => x.endsWith(".mp4"));
|
||||
if (!video || !process.argv[2])
|
||||
throw Error("Local reference video and ffmpeg path required");
|
||||
const rate = 24000;
|
||||
const pcm = execFileSync(
|
||||
process.argv[2],
|
||||
[
|
||||
"-v",
|
||||
"error",
|
||||
"-ss",
|
||||
"6.76",
|
||||
"-i",
|
||||
video,
|
||||
"-t",
|
||||
"33.24",
|
||||
"-vn",
|
||||
"-af",
|
||||
"pan=mono|c0=0.5*c0+0.5*c1",
|
||||
"-ac",
|
||||
"1",
|
||||
"-ar",
|
||||
String(rate),
|
||||
"-f",
|
||||
"f32le",
|
||||
"pipe:1",
|
||||
],
|
||||
{ maxBuffer: 16 * 1024 * 1024 },
|
||||
);
|
||||
const samples = new Float32Array(pcm.buffer, pcm.byteOffset, pcm.length / 4);
|
||||
const bounds = [6.76, 9.16, 11.12, 19.48, 22.76, 26.92, 30.68, 33.3, 40];
|
||||
const stages = bounds.slice(0, -1).map((start, i) => {
|
||||
const data = samples.subarray(
|
||||
Math.round((start - 6.76) * rate),
|
||||
Math.round((bounds[i + 1] - 6.76) * rate),
|
||||
);
|
||||
let peak = 0,
|
||||
sum = 0;
|
||||
for (const x of data) {
|
||||
peak = Math.max(peak, Math.abs(x));
|
||||
sum += x * x;
|
||||
}
|
||||
return {
|
||||
start,
|
||||
end: bounds[i + 1],
|
||||
peakDb: +(20 * Math.log10(peak)).toFixed(2),
|
||||
rmsDb: +(10 * Math.log10(sum / data.length)).toFixed(2),
|
||||
};
|
||||
});
|
||||
fs.writeFileSync(
|
||||
"reference/audio-analysis.json",
|
||||
JSON.stringify(
|
||||
{
|
||||
video,
|
||||
scope:
|
||||
"6.76–40 s, mono downmix; not a transcription or melody identification",
|
||||
stages,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
);
|
||||
console.log(stages);
|
||||
@@ -0,0 +1,100 @@
|
||||
import fs from "node:fs/promises";
|
||||
|
||||
const requiredFields = [
|
||||
"id",
|
||||
"title",
|
||||
"en",
|
||||
"department",
|
||||
"category",
|
||||
"date",
|
||||
"lead",
|
||||
"clearance",
|
||||
"abstract",
|
||||
"source",
|
||||
];
|
||||
const isText = (value) => typeof value === "string" && value.trim().length > 0;
|
||||
|
||||
export function validateContent(content) {
|
||||
const errors = [];
|
||||
if (!content || typeof content !== "object" || Array.isArray(content)) {
|
||||
throw new Error("档案数据必须是 JSON 对象。");
|
||||
}
|
||||
for (const key of ["categories", "columns"]) {
|
||||
const names = content[key];
|
||||
if (!Array.isArray(names) || names.length !== 5 || !names.every(isText)) {
|
||||
errors.push(`${key}:必须包含五个非空分类名称`);
|
||||
} else if (new Set(names).size !== 5 || names.includes("全部档案")) {
|
||||
errors.push(`${key}:分类名称不能重复,也不能使用“全部档案”`);
|
||||
}
|
||||
}
|
||||
const categories = Array.isArray(content.categories)
|
||||
? content.categories
|
||||
: [];
|
||||
const columns = Array.isArray(content.columns) ? content.columns : [];
|
||||
if (
|
||||
categories.some((name) => !columns.includes(name)) ||
|
||||
columns.some((name) => !categories.includes(name))
|
||||
) {
|
||||
errors.push("categories 与 columns 必须包含相同的五个分类(顺序可以不同)");
|
||||
}
|
||||
const records = Array.isArray(content.records) ? content.records : [];
|
||||
// 每列条数不设上限:列内不足八条时阵列会循环重复,超过八条就向外扩充。
|
||||
// 唯一的下限是每列至少一条,否则那一列无可显示。
|
||||
if (records.length < 5) errors.push("records:至少需要每个分类一条档案");
|
||||
// 编号宽度随总量自适应(≤999 时仍为三位,与原版一致)。
|
||||
const idWidth = Math.max(3, String(records.length).length);
|
||||
const ids = new Set();
|
||||
records.forEach((record, index) => {
|
||||
const label = `records[${index}]`;
|
||||
if (!record || typeof record !== "object" || Array.isArray(record)) {
|
||||
errors.push(`${label}:必须是档案对象`);
|
||||
return;
|
||||
}
|
||||
for (const key of requiredFields) {
|
||||
if (!isText(record[key])) errors.push(`${label}.${key}:必须是非空文本`);
|
||||
}
|
||||
const expectedId = `X-${String(index + 1).padStart(idWidth, "0")}`;
|
||||
if (record.id !== expectedId)
|
||||
errors.push(`${label}.id:应为 ${expectedId},编号须按顺序保持稳定`);
|
||||
if (ids.has(record.id)) errors.push(`${label}.id:重复编号 ${record.id}`);
|
||||
ids.add(record.id);
|
||||
if (!categories.includes(record.category))
|
||||
errors.push(`${label}.category:未知分类 ${record.category}`);
|
||||
if (
|
||||
!Array.isArray(record.findings) ||
|
||||
record.findings.length === 0 ||
|
||||
!record.findings.every(isText)
|
||||
) {
|
||||
errors.push(`${label}.findings:必须包含至少一条非空研究记录`);
|
||||
}
|
||||
try {
|
||||
const url = new URL(record.source);
|
||||
// 本地归档条目用 file: 指向真实目录;保留 http(s) 以兼容公开设定类条目。
|
||||
if (!["https:", "http:", "file:"].includes(url.protocol)) throw new Error();
|
||||
} catch {
|
||||
errors.push(`${label}.source:必须是有效的 HTTP、HTTPS 或 file 链接`);
|
||||
}
|
||||
});
|
||||
for (const name of columns) {
|
||||
const count = records.filter((record) => record?.category === name).length;
|
||||
if (count < 1) errors.push(`分类“${name}”:至少要有一条档案`);
|
||||
}
|
||||
if (errors.length)
|
||||
throw new Error(`档案数据校验失败:\n- ${errors.join("\n- ")}`);
|
||||
return content;
|
||||
}
|
||||
|
||||
export async function loadContent() {
|
||||
return validateContent(
|
||||
JSON.parse(
|
||||
await fs.readFile(
|
||||
new URL("../content/archives.json", import.meta.url),
|
||||
"utf8",
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function archiveText(r) {
|
||||
return `\uFEFFRHINE LAB · LOCAL ARCHIVE INDEX\nFILE ${r.id} / ${r.title}\n${r.en}\n\n归档位置:${r.department}\n编目范围:${r.date}\n来源:${r.lead}\n访问范围:${r.clearance}\n\n${r.abstract}\n\n研究记录\n${r.findings.map((f, i) => `${i + 1}. ${f}`).join("\n")}\n\n归档参考:${r.source}\n本文件是本地归档的编目摘要,由 scripts/archive-source.mjs 依据实际目录生成。\n`;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// 一条命令同时拉起本地归档服务与 Vite 开发服务器。
|
||||
// 服务先起(生成令牌与快照),随后 Vite 通过 /archive-api 代理访问它。
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const tokenFile = path.join(projectRoot, ".archive-token");
|
||||
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
|
||||
|
||||
// 令牌是每次启动新生成的,先删掉旧值,避免 Vite 代理到已经退出的旧服务。
|
||||
fs.rmSync(tokenFile, { force: true });
|
||||
|
||||
const children = [];
|
||||
const shutdown = (code = 0) => {
|
||||
for (const child of children) {
|
||||
if (!child.killed) child.kill();
|
||||
}
|
||||
process.exit(code);
|
||||
};
|
||||
process.on("SIGINT", () => shutdown(0));
|
||||
process.on("SIGTERM", () => shutdown(0));
|
||||
|
||||
const service = spawn(process.execPath, ["scripts/archive-service.mjs"], {
|
||||
cwd: projectRoot,
|
||||
stdio: "inherit",
|
||||
});
|
||||
children.push(service);
|
||||
service.on("exit", (code) => {
|
||||
if (code !== 0 && code !== null) {
|
||||
console.error(`归档服务已退出(代码 ${code}),同时停止开发服务器。`);
|
||||
shutdown(code);
|
||||
}
|
||||
});
|
||||
|
||||
// 等令牌文件出现,确保 Vite 启动时代理已能取到凭据。
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (!fs.existsSync(tokenFile)) {
|
||||
if (Date.now() > deadline) {
|
||||
console.error("等待归档服务启动超时。");
|
||||
shutdown(1);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
|
||||
console.log("归档服务就绪,启动 Vite 开发服务器……");
|
||||
const vite = spawn(npm, ["run", "dev", "--", ...process.argv.slice(2)], {
|
||||
cwd: projectRoot,
|
||||
stdio: "inherit",
|
||||
shell: process.platform === "win32",
|
||||
});
|
||||
children.push(vite);
|
||||
vite.on("exit", (code) => shutdown(code ?? 0));
|
||||
@@ -0,0 +1,8 @@
|
||||
' Generated by archive-service.ps1 - do not edit by hand.
|
||||
' Purpose: start the archive backend with a fully hidden window (style 0), so
|
||||
' that logging on does not flash a black console. Also wraps it so the backend
|
||||
' keeps its own log file. NOTE: this file is ASCII-only on purpose - wscript
|
||||
' reads .vbs in the system ANSI code page, so non-ASCII here would garble.
|
||||
Set sh = CreateObject("WScript.Shell")
|
||||
sh.CurrentDirectory = "E:\deepseek\RhineLabUI"
|
||||
sh.Run "powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File " & Chr(34) & "E:\deepseek\RhineLabUI\scripts\archive-service.ps1" & Chr(34) & " -Action run", 0, False
|
||||
@@ -0,0 +1,447 @@
|
||||
// 本地归档服务:只监听 127.0.0.1,用每次启动生成的令牌限制网页访问。
|
||||
//
|
||||
// 职责:
|
||||
// 1. 监视归档根目录,变动后自动重新生成快照(对应"有新文件自动更新")
|
||||
// 2. 提供检索索引(gzip)
|
||||
// 3. 提供读操作:打开文件、在资源管理器中定位
|
||||
// 4. 提供安全写操作:新建分类目录、移动、复制、解压;一律先 plan 预览再 apply
|
||||
// —— 不提供删除与重命名
|
||||
//
|
||||
// 所有写操作都校验解析后的绝对路径必须落在归档根目录内,并追加到操作日志。
|
||||
|
||||
import fs from "node:fs";
|
||||
import fsp from "node:fs/promises";
|
||||
import http from "node:http";
|
||||
import path from "node:path";
|
||||
import zlib from "node:zlib";
|
||||
import crypto from "node:crypto";
|
||||
import { spawn } from "node:child_process";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { loadConfig, regenerate } from "./archive-source.mjs";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.resolve(here, "..");
|
||||
const SEVEN_ZIP = "C:\\Program Files\\7-Zip\\7z.exe";
|
||||
|
||||
const config = await loadConfig();
|
||||
const PORT = config.port ?? 43117;
|
||||
const TOKEN = crypto.randomBytes(24).toString("hex");
|
||||
const TOKEN_FILE = path.join(projectRoot, ".archive-token");
|
||||
await fsp.writeFile(TOKEN_FILE, TOKEN, "utf8");
|
||||
|
||||
const state = {
|
||||
watching: false,
|
||||
lastScan: null,
|
||||
lastError: null,
|
||||
pendingRescan: null,
|
||||
};
|
||||
|
||||
// ---------- 归档根目录围栏 ----------
|
||||
// 客户端一律传相对路径(/ 分隔)。解析后必须仍在根目录内,且真实路径也不得越界。
|
||||
function resolveInside(relPath) {
|
||||
if (typeof relPath !== "string" || relPath.includes("\0"))
|
||||
throw httpError(400, "路径不合法");
|
||||
const cleaned = relPath.replace(/^[/\\]+/, "").replace(/\//g, path.sep);
|
||||
if (!cleaned) throw httpError(400, "路径为空");
|
||||
const abs = path.resolve(config.root, cleaned);
|
||||
const rel = path.relative(config.root, abs);
|
||||
if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel))
|
||||
throw httpError(403, "只允许访问归档目录内的路径");
|
||||
return abs;
|
||||
}
|
||||
|
||||
function realInside(abs) {
|
||||
let real;
|
||||
try {
|
||||
real = fs.realpathSync(abs);
|
||||
} catch {
|
||||
return abs; // 还不存在(例如待新建目录);上一级已校验
|
||||
}
|
||||
const rel = path.relative(fs.realpathSync(config.root), real);
|
||||
if (rel.startsWith("..") || path.isAbsolute(rel))
|
||||
throw httpError(403, "解析后的真实路径越出归档目录");
|
||||
return real;
|
||||
}
|
||||
|
||||
function httpError(status, message) {
|
||||
const error = new Error(message);
|
||||
error.status = status;
|
||||
return error;
|
||||
}
|
||||
|
||||
// ---------- 统计与日志 ----------
|
||||
function statsOf(abs) {
|
||||
const out = { exists: false, isDir: false, bytes: 0, files: 0 };
|
||||
let stat;
|
||||
try {
|
||||
stat = fs.lstatSync(abs);
|
||||
} catch {
|
||||
return out;
|
||||
}
|
||||
out.exists = true;
|
||||
out.isDir = stat.isDirectory();
|
||||
if (!out.isDir) {
|
||||
out.bytes = stat.size;
|
||||
out.files = 1;
|
||||
return out;
|
||||
}
|
||||
const walk = (dir) => {
|
||||
let items;
|
||||
try {
|
||||
items = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const item of items) {
|
||||
const child = path.join(dir, item.name);
|
||||
let s;
|
||||
try {
|
||||
s = fs.lstatSync(child);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (s.isDirectory()) walk(child);
|
||||
else if (s.isFile()) {
|
||||
out.files += 1;
|
||||
out.bytes += s.size;
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(abs);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function writeLog(entry) {
|
||||
const logRel = config.logFile ?? "00_索引\\操作日志.jsonl";
|
||||
const logAbs = path.join(config.root, logRel);
|
||||
await fsp.mkdir(path.dirname(logAbs), { recursive: true });
|
||||
await fsp.appendFile(
|
||||
logAbs,
|
||||
`${JSON.stringify({ at: new Date().toISOString(), ...entry })}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
const sameVolume = (a, b) =>
|
||||
path.parse(a).root.toLowerCase() === path.parse(b).root.toLowerCase();
|
||||
|
||||
// ---------- 操作计划(读操作也能生成计划) ----------
|
||||
function buildPlan({ op, from, to, folder }) {
|
||||
switch (op) {
|
||||
case "mkdir": {
|
||||
const abs = resolveInside(folder);
|
||||
const exists = fs.existsSync(abs);
|
||||
return {
|
||||
op,
|
||||
from: null,
|
||||
to: path.relative(config.root, abs).replace(/\\/g, "/"),
|
||||
targetExists: exists,
|
||||
blocked: exists ? "目标目录已存在" : null,
|
||||
note: exists ? null : "将新建一个分类目录",
|
||||
bytes: 0,
|
||||
files: 0,
|
||||
};
|
||||
}
|
||||
case "move":
|
||||
case "copy": {
|
||||
const srcAbs = realInside(resolveInside(from));
|
||||
const dstAbs = resolveInside(to);
|
||||
const src = statsOf(srcAbs);
|
||||
if (!src.exists) return { op, from, to, blocked: "源不存在", bytes: 0, files: 0 };
|
||||
const targetExists = fs.existsSync(dstAbs);
|
||||
const crossVolume = !sameVolume(srcAbs, dstAbs);
|
||||
const blocked =
|
||||
op === "move" && crossVolume
|
||||
? "跨盘移动不被允许(请改用复制)"
|
||||
: targetExists
|
||||
? "目标已存在,请换一个名字"
|
||||
: null;
|
||||
return {
|
||||
op,
|
||||
from: path.relative(config.root, srcAbs).replace(/\\/g, "/"),
|
||||
to: path.relative(config.root, dstAbs).replace(/\\/g, "/"),
|
||||
bytes: src.bytes,
|
||||
files: src.files,
|
||||
isDir: src.isDir,
|
||||
targetExists,
|
||||
crossVolume,
|
||||
blocked,
|
||||
note: src.isDir
|
||||
? `目录,含 ${src.files} 个文件`
|
||||
: `文件,${src.bytes} 字节`,
|
||||
};
|
||||
}
|
||||
case "extract": {
|
||||
const srcAbs = realInside(resolveInside(from));
|
||||
if (!fs.existsSync(srcAbs)) return { op, from, to, blocked: "压缩包不存在", bytes: 0, files: 0 };
|
||||
const dstAbs = resolveInside(to);
|
||||
const dst = statsOf(dstAbs);
|
||||
const targetExists = dst.exists && dst.files > 0;
|
||||
return {
|
||||
op,
|
||||
from: path.relative(config.root, srcAbs).replace(/\\/g, "/"),
|
||||
to: path.relative(config.root, dstAbs).replace(/\\/g, "/"),
|
||||
bytes: fs.statSync(srcAbs).size,
|
||||
files: 0,
|
||||
targetExists,
|
||||
blocked: targetExists ? "目标目录已存在且非空" : null,
|
||||
note: `解压到 ${path.relative(config.root, dstAbs).replace(/\\/g, "/") || "."}`,
|
||||
};
|
||||
}
|
||||
default:
|
||||
throw httpError(400, `不支持的操作:${op}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function applyPlan({ op, from, to, folder }) {
|
||||
const plan = buildPlan({ op, from, to, folder });
|
||||
if (plan.blocked) throw httpError(409, plan.blocked);
|
||||
|
||||
if (op === "mkdir") {
|
||||
const abs = resolveInside(folder);
|
||||
await fsp.mkdir(abs, { recursive: true });
|
||||
} else if (op === "move") {
|
||||
const abs = resolveInside(to);
|
||||
await fsp.mkdir(path.dirname(abs), { recursive: true });
|
||||
await fsp.rename(resolveInside(from), abs);
|
||||
} else if (op === "copy") {
|
||||
const abs = resolveInside(to);
|
||||
await fsp.mkdir(path.dirname(abs), { recursive: true });
|
||||
await fsp.cp(resolveInside(from), abs, { recursive: true, errorOnExist: true });
|
||||
} else if (op === "extract") {
|
||||
if (!fs.existsSync(SEVEN_ZIP)) throw httpError(500, `找不到 7-Zip:${SEVEN_ZIP}`);
|
||||
const abs = resolveInside(to);
|
||||
await fsp.mkdir(abs, { recursive: true });
|
||||
const code = await new Promise((resolve) => {
|
||||
const child = spawn(
|
||||
SEVEN_ZIP,
|
||||
["x", resolveInside(from), `-o${abs}`, "-y", "-bso0", "-bsp0"],
|
||||
{ stdio: "ignore", windowsHide: true },
|
||||
);
|
||||
child.on("close", resolve);
|
||||
child.on("error", () => resolve(-1));
|
||||
});
|
||||
if (code !== 0) throw httpError(500, `7-Zip 解压失败(退出码 ${code})`);
|
||||
}
|
||||
|
||||
await writeLog({ op, from: plan.from, to: plan.to, result: "ok" });
|
||||
scheduleRescan();
|
||||
return plan;
|
||||
}
|
||||
|
||||
// ---------- 读操作:交给系统 ----------
|
||||
function reveal(abs) {
|
||||
spawn("explorer.exe", [`/select,${abs}`], { stdio: "ignore", windowsHide: false }).unref();
|
||||
}
|
||||
function openWith(abs) {
|
||||
spawn("cmd.exe", ["/c", "start", "", abs], { stdio: "ignore", windowsHide: true }).unref();
|
||||
}
|
||||
|
||||
// ---------- 快照刷新与自动更新 ----------
|
||||
let timer = null;
|
||||
function scheduleRescan(delay = 2500) {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(async () => {
|
||||
try {
|
||||
const result = await regenerate({ quiet: true });
|
||||
state.lastScan = result.generated;
|
||||
state.lastError = null;
|
||||
broadcast({ type: "rescan", generated: result.generated, totals: result.totals });
|
||||
} catch (error) {
|
||||
state.lastError = error.message;
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
|
||||
const clients = new Set();
|
||||
function broadcast(message) {
|
||||
const line = `data: ${JSON.stringify(message)}\n\n`;
|
||||
for (const res of clients) {
|
||||
try {
|
||||
res.write(line);
|
||||
} catch {
|
||||
clients.delete(res);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startWatcher() {
|
||||
try {
|
||||
fs.watch(config.root, { recursive: true }, (_event, filename) => {
|
||||
if (filename && String(filename).includes("操作日志.jsonl")) return;
|
||||
scheduleRescan();
|
||||
});
|
||||
state.watching = true;
|
||||
} catch (error) {
|
||||
state.watching = false;
|
||||
state.lastError = `无法监视归档目录:${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- HTTP ----------
|
||||
const indexAbs = path.join(projectRoot, config.indexFile ?? "public/archive-index.json");
|
||||
let indexCache = { mtime: 0, gzip: null, raw: null };
|
||||
|
||||
function readIndex() {
|
||||
const mtime = fs.existsSync(indexAbs) ? fs.statSync(indexAbs).mtimeMs : 0;
|
||||
if (indexCache.mtime !== mtime) {
|
||||
const raw = mtime ? fs.readFileSync(indexAbs) : Buffer.from("{}");
|
||||
indexCache = { mtime, raw, gzip: zlib.gzipSync(raw) };
|
||||
}
|
||||
return indexCache;
|
||||
}
|
||||
|
||||
function searchIndex(query, limit) {
|
||||
const { raw } = readIndex();
|
||||
const payload = JSON.parse(raw.toString("utf8"));
|
||||
const needle = query.trim().toLowerCase();
|
||||
if (!needle) return { total: payload.entries.length, hits: [] };
|
||||
const hits = [];
|
||||
for (const entry of payload.entries) {
|
||||
if (entry[0].toLowerCase().includes(needle)) {
|
||||
hits.push(entry);
|
||||
if (hits.length >= limit) break;
|
||||
}
|
||||
}
|
||||
return { total: hits.length, hits };
|
||||
}
|
||||
|
||||
async function readBody(req) {
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
for await (const chunk of req) {
|
||||
size += chunk.length;
|
||||
if (size > 1_000_000) throw httpError(413, "请求体过大");
|
||||
chunks.push(chunk);
|
||||
}
|
||||
if (!chunks.length) return {};
|
||||
try {
|
||||
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||||
} catch {
|
||||
throw httpError(400, "请求体不是合法 JSON");
|
||||
}
|
||||
}
|
||||
|
||||
const send = (res, status, body, headers = {}) => {
|
||||
const isBuffer = Buffer.isBuffer(body);
|
||||
const payload = isBuffer ? body : Buffer.from(JSON.stringify(body));
|
||||
res.writeHead(status, {
|
||||
"content-type": isBuffer ? "application/octet-stream" : "application/json; charset=utf-8",
|
||||
"cache-control": "no-store",
|
||||
...headers,
|
||||
});
|
||||
res.end(payload);
|
||||
};
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url ?? "/", `http://127.0.0.1:${PORT}`);
|
||||
const route = url.pathname;
|
||||
|
||||
// 事件流不需要令牌以外的额外处理,但同样要求令牌
|
||||
if (req.headers["x-archive-token"] !== TOKEN) {
|
||||
return send(res, 401, { error: "访问令牌无效" });
|
||||
}
|
||||
|
||||
if (route === "/events") {
|
||||
res.writeHead(200, {
|
||||
"content-type": "text/event-stream",
|
||||
"cache-control": "no-store",
|
||||
connection: "keep-alive",
|
||||
});
|
||||
res.write(": connected\n\n");
|
||||
clients.add(res);
|
||||
req.on("close", () => clients.delete(res));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (route === "/status" && req.method === "GET") {
|
||||
const { raw } = readIndex();
|
||||
const payload = JSON.parse(raw.toString("utf8"));
|
||||
return send(res, 200, {
|
||||
root: config.root,
|
||||
watching: state.watching,
|
||||
lastScan: state.lastScan ?? payload.generated ?? null,
|
||||
lastError: state.lastError,
|
||||
totals: payload.totals ?? null,
|
||||
port: PORT,
|
||||
sevenZip: fs.existsSync(SEVEN_ZIP),
|
||||
});
|
||||
}
|
||||
|
||||
if (route === "/index" && req.method === "GET") {
|
||||
const { raw, gzip } = readIndex();
|
||||
if (String(req.headers["accept-encoding"] ?? "").includes("gzip"))
|
||||
return send(res, 200, gzip, {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"content-encoding": "gzip",
|
||||
});
|
||||
return send(res, 200, raw, { "content-type": "application/json; charset=utf-8" });
|
||||
}
|
||||
|
||||
if (route === "/search" && req.method === "GET") {
|
||||
const q = url.searchParams.get("q") ?? "";
|
||||
const limit = Math.min(Number(url.searchParams.get("limit") ?? 200) || 200, 2000);
|
||||
return send(res, 200, searchIndex(q, limit));
|
||||
}
|
||||
|
||||
if (route === "/plan" && req.method === "POST") {
|
||||
const body = await readBody(req);
|
||||
return send(res, 200, buildPlan(body));
|
||||
}
|
||||
|
||||
if (route === "/apply" && req.method === "POST") {
|
||||
const body = await readBody(req);
|
||||
if (body.confirm !== true) throw httpError(400, "写操作需要 confirm: true");
|
||||
const plan = await applyPlan(body);
|
||||
return send(res, 200, { ok: true, plan });
|
||||
}
|
||||
|
||||
if (route === "/reveal" && req.method === "POST") {
|
||||
const body = await readBody(req);
|
||||
const abs = realInside(resolveInside(body.path));
|
||||
if (!fs.existsSync(abs)) throw httpError(404, "路径不存在");
|
||||
reveal(abs);
|
||||
return send(res, 200, { ok: true, action: "reveal", path: body.path });
|
||||
}
|
||||
|
||||
if (route === "/open" && req.method === "POST") {
|
||||
const body = await readBody(req);
|
||||
const abs = realInside(resolveInside(body.path));
|
||||
if (!fs.existsSync(abs)) throw httpError(404, "路径不存在");
|
||||
openWith(abs);
|
||||
return send(res, 200, { ok: true, action: "open", path: body.path });
|
||||
}
|
||||
|
||||
if (route === "/refresh" && req.method === "POST") {
|
||||
const result = await regenerate({ quiet: true });
|
||||
state.lastScan = result.generated;
|
||||
state.lastError = null;
|
||||
broadcast({ type: "rescan", generated: result.generated, totals: result.totals });
|
||||
return send(res, 200, { ok: true, generated: result.generated, totals: result.totals });
|
||||
}
|
||||
|
||||
return send(res, 404, { error: `未知接口:${route}` });
|
||||
} catch (error) {
|
||||
return send(res, error.status ?? 500, { error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(PORT, "127.0.0.1", async () => {
|
||||
console.log(`归档服务已启动:http://127.0.0.1:${PORT}`);
|
||||
console.log(`归档根目录:${config.root}`);
|
||||
console.log(`访问令牌:已写入 ${path.relative(projectRoot, TOKEN_FILE)}`);
|
||||
try {
|
||||
const result = await regenerate({ quiet: true });
|
||||
state.lastScan = result.generated;
|
||||
console.log(
|
||||
`快照就绪:${result.records.length} 条精选档案,` +
|
||||
`${result.totals.files} 个文件 / ${result.totals.dirs} 个目录`,
|
||||
);
|
||||
} catch (error) {
|
||||
state.lastError = error.message;
|
||||
console.error(`首次生成快照失败:${error.message}`);
|
||||
}
|
||||
startWatcher();
|
||||
console.log(state.watching ? "已开始监视归档变动(自动更新快照)" : "监视未启用");
|
||||
});
|
||||
@@ -0,0 +1,418 @@
|
||||
<#
|
||||
Rhine Lab UI · 本地归档后端(「数据库」)服务管理
|
||||
|
||||
这个后端就是 RhineLabUI 的数据层:它索引 E:\归档(当前 4.5 万文件 / 110 GB),
|
||||
生成 content/archives.json、public/archive-index.json 与 47 份摘要,并在归档变动时
|
||||
自动重建快照;界面上的 LOCAL ARCHIVE 面板与「文件位置」都靠它。
|
||||
它只监听 127.0.0.1,且每次启动生成一枚访问令牌写入 .archive-token。
|
||||
|
||||
常用:
|
||||
install 安装开机自启(登录时触发)+ 立即启动 ← 一次性
|
||||
ensure 没在跑就拉起,在跑就什么都不做 ← 一键脚本会调
|
||||
status 看状态(任务 / 端口 / 进程 / 快照 / 归档根)
|
||||
stop 停掉后端
|
||||
uninstall 取消开机自启并停掉后端
|
||||
logs 看后端最近的输出
|
||||
|
||||
自启的两种装法(install 会自己挑):
|
||||
· 管理员会话 → 计划任务 WpywArchiveService(登录触发、失败自动重试 3 次)
|
||||
· 普通会话 → 「启动」文件夹里的快捷方式(不需要提权,效果相同)
|
||||
实测本机普通会话注册计划任务会被「拒绝访问」,所以默认走「启动」文件夹这条。
|
||||
|
||||
为什么用「登录时」而不是「开机 + SYSTEM」:
|
||||
1) 服务要用 explorer.exe /select 打开资源管理器定位文件(SHOW IN FOLDER)。
|
||||
以 SYSTEM 跑会落在会话 0,用户屏幕上什么都不会出现 —— 那功能会静默失效。
|
||||
2) 以当前用户跑,它写出的 archives.json / archive-index.json 归属正常,
|
||||
用户随后自己跑 npm run build 不会被权限挡住。
|
||||
3) 开机触发要求把账户口令存进计划任务,本机没有这个必要。
|
||||
结论:本机单人使用,登录即等于开机可用,且功能无损。窗口由 VBS 隐藏,不弹黑框。
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[ValidateSet('ensure', 'start', 'stop', 'restart', 'status', 'install', 'uninstall', 'logs', 'run')]
|
||||
[string]$Action = 'status'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$TaskName = 'WpywArchiveService'
|
||||
$ProjectRoot = Split-Path -Parent $PSScriptRoot
|
||||
$ConfigPath = Join-Path $ProjectRoot 'archive.config.json'
|
||||
$ServiceJs = Join-Path $ProjectRoot 'scripts\archive-service.mjs'
|
||||
$TokenFile = Join-Path $ProjectRoot '.archive-token'
|
||||
$LogDir = Join-Path $ProjectRoot 'logs'
|
||||
$LogFile = Join-Path $LogDir 'archive-service.log'
|
||||
$HiddenVbs = Join-Path $ProjectRoot 'scripts\archive-service-hidden.vbs'
|
||||
$WScript = Join-Path $env:SystemRoot 'System32\wscript.exe'
|
||||
$StartupLnk = Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::Startup)) '归档后端 (Rhine Lab).lnk'
|
||||
|
||||
function Write-Head($text) { Write-Host ''; Write-Host " $text" -ForegroundColor Cyan }
|
||||
function Write-Item($label, $value) {
|
||||
Write-Host (' {0,-12} {1}' -f $label, $value)
|
||||
}
|
||||
function Write-Ok($text) { Write-Host " $text" -ForegroundColor Green }
|
||||
function Write-Info2($text) { Write-Host " $text" -ForegroundColor Gray }
|
||||
function Write-Warn2($text) { Write-Host " $text" -ForegroundColor Yellow }
|
||||
|
||||
function Get-NodeExe {
|
||||
$cmd = Get-Command node.exe -ErrorAction SilentlyContinue
|
||||
if ($cmd -and $cmd.Source) { return $cmd.Source }
|
||||
$fallback = 'C:\Program Files\nodejs\node.exe'
|
||||
if (Test-Path -LiteralPath $fallback) { return $fallback }
|
||||
throw '找不到 node.exe,请先安装 Node.js。'
|
||||
}
|
||||
|
||||
function Get-ArchiveConfig {
|
||||
if (-not (Test-Path -LiteralPath $ConfigPath)) { throw "缺少配置文件:$ConfigPath" }
|
||||
return (Get-Content -LiteralPath $ConfigPath -Raw -Encoding UTF8 | ConvertFrom-Json)
|
||||
}
|
||||
|
||||
function Get-Port {
|
||||
$cfg = Get-ArchiveConfig
|
||||
if ($cfg.port) { return [int]$cfg.port }
|
||||
return 43117
|
||||
}
|
||||
|
||||
function Get-Token {
|
||||
if (-not (Test-Path -LiteralPath $TokenFile)) { return '' }
|
||||
return (Get-Content -LiteralPath $TokenFile -Raw).Trim()
|
||||
}
|
||||
|
||||
function Get-ListenerProcessId {
|
||||
param([int]$Port)
|
||||
$conn = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue |
|
||||
Select-Object -First 1
|
||||
if ($conn) { return [int]$conn.OwningProcess }
|
||||
return 0
|
||||
}
|
||||
|
||||
function Get-CommandLineOf {
|
||||
param([int]$ProcessId)
|
||||
$p = Get-CimInstance Win32_Process -Filter "ProcessId=$ProcessId" -ErrorAction SilentlyContinue
|
||||
if ($p) { return [string]$p.CommandLine }
|
||||
return ''
|
||||
}
|
||||
|
||||
function Test-BackendListening {
|
||||
param([int]$Port)
|
||||
$procId = Get-ListenerProcessId -Port $Port
|
||||
if ($procId -le 0) { return $false }
|
||||
return ((Get-CommandLineOf -ProcessId $procId) -like '*archive-service.mjs*')
|
||||
}
|
||||
|
||||
function Get-BackendState {
|
||||
param([int]$Port)
|
||||
$state = [ordered]@{
|
||||
listening = $false; pid = 0; healthy = $false
|
||||
watching = $false; lastScan = $null; totals = $null; root = $null; lastError = $null; httpError = $null
|
||||
}
|
||||
$procId = Get-ListenerProcessId -Port $Port
|
||||
if ($procId -le 0) { return $state }
|
||||
if ((Get-CommandLineOf -ProcessId $procId) -notlike '*archive-service.mjs*') { return $state }
|
||||
$state.listening = $true
|
||||
$state.pid = $procId
|
||||
try {
|
||||
$headers = @{}
|
||||
$token = Get-Token
|
||||
if ($token) { $headers['x-archive-token'] = $token }
|
||||
$r = Invoke-RestMethod -Uri "http://127.0.0.1:$Port/status" -Headers $headers -TimeoutSec 5
|
||||
$state.healthy = $true
|
||||
$state.watching = [bool]$r.watching
|
||||
$state.lastScan = $r.lastScan
|
||||
$state.totals = $r.totals
|
||||
$state.root = $r.root
|
||||
$state.lastError = $r.lastError
|
||||
} catch {
|
||||
$state.httpError = $_.Exception.Message
|
||||
}
|
||||
return $state
|
||||
}
|
||||
|
||||
function Wait-BackendReady {
|
||||
param([int]$Port, [int]$TimeoutSec = 150)
|
||||
$deadline = (Get-Date).AddSeconds($TimeoutSec)
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
$s = Get-BackendState -Port $Port
|
||||
# watching 只有首轮扫描 + 快照写完之后才会为真
|
||||
if ($s.healthy -and $s.watching) { return $s }
|
||||
Start-Sleep -Milliseconds 800
|
||||
}
|
||||
return (Get-BackendState -Port $Port)
|
||||
}
|
||||
|
||||
function Write-HiddenLauncher {
|
||||
$node = Get-NodeExe
|
||||
$ps1 = $PSCommandPath
|
||||
$lines = @(
|
||||
"' Generated by archive-service.ps1 - do not edit by hand."
|
||||
"' Purpose: start the archive backend with a fully hidden window (style 0), so"
|
||||
"' that logging on does not flash a black console. Also wraps it so the backend"
|
||||
"' keeps its own log file. NOTE: this file is ASCII-only on purpose - wscript"
|
||||
"' reads .vbs in the system ANSI code page, so non-ASCII here would garble."
|
||||
'Set sh = CreateObject("WScript.Shell")'
|
||||
('sh.CurrentDirectory = "' + $ProjectRoot + '"')
|
||||
('sh.Run "powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File " & Chr(34) & "' + $ps1 + '" & Chr(34) & " -Action run", 0, False')
|
||||
)
|
||||
Set-Content -LiteralPath $HiddenVbs -Value $lines -Encoding ASCII
|
||||
return $HiddenVbs
|
||||
}
|
||||
|
||||
function Test-Elevated {
|
||||
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
return (New-Object Security.Principal.WindowsPrincipal($identity)).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||
}
|
||||
|
||||
function Install-ScheduledTaskAutostart {
|
||||
$vbs = Write-HiddenLauncher
|
||||
$userId = "$env:USERDOMAIN\$env:USERNAME"
|
||||
|
||||
$action = New-ScheduledTaskAction -Execute $WScript -Argument ('"{0}"' -f $vbs) -WorkingDirectory $ProjectRoot
|
||||
$trigger = New-ScheduledTaskTrigger -AtLogOn -User $userId
|
||||
$taskPrincipal = New-ScheduledTaskPrincipal -UserId $userId -LogonType Interactive -RunLevel Highest
|
||||
$settings = New-ScheduledTaskSettingsSet `
|
||||
-AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
|
||||
-ExecutionTimeLimit ([TimeSpan]::Zero) `
|
||||
-RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 2) `
|
||||
-MultipleInstances IgnoreNew -StartWhenAvailable
|
||||
|
||||
Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger `
|
||||
-Principal $taskPrincipal -Settings $settings -Force -ErrorAction Stop `
|
||||
-Description '莱茵生命档案 UI 的本地归档后端(127.0.0.1:43117)。登录时后台静默启动,随归档变动自动重建索引。' | Out-Null
|
||||
|
||||
Write-Ok "已注册计划任务 $TaskName(登录触发 / 后台静默 / 失败自动重试 3 次)"
|
||||
}
|
||||
|
||||
function Install-StartupShortcut {
|
||||
$vbs = Write-HiddenLauncher
|
||||
$dir = [Environment]::GetFolderPath([Environment+SpecialFolder]::Startup)
|
||||
if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
|
||||
|
||||
$shell = New-Object -ComObject WScript.Shell
|
||||
$lnk = $shell.CreateShortcut($StartupLnk)
|
||||
$lnk.TargetPath = $WScript
|
||||
$lnk.Arguments = '"' + $vbs + '"'
|
||||
$lnk.WorkingDirectory = $ProjectRoot
|
||||
$lnk.WindowStyle = 7
|
||||
$lnk.Description = '莱茵生命档案 UI 的本地归档后端(登录时后台静默启动)'
|
||||
$lnk.Save()
|
||||
[void][Runtime.InteropServices.Marshal]::ReleaseComObject($shell)
|
||||
|
||||
Write-Ok "已在「启动」文件夹放置快捷方式:$StartupLnk"
|
||||
Write-Info2 '登录后自动在后台静默启动,不弹窗口;删掉这个快捷方式即取消自启。'
|
||||
}
|
||||
|
||||
function Install-Autostart {
|
||||
$vbs = Write-HiddenLauncher
|
||||
|
||||
if (Test-Elevated) {
|
||||
try {
|
||||
Install-ScheduledTaskAutostart
|
||||
} catch {
|
||||
Write-Warn2 "计划任务注册失败($($_.Exception.Message)),改用「启动」文件夹。"
|
||||
Install-StartupShortcut
|
||||
}
|
||||
} else {
|
||||
Write-Info2 '当前不是管理员会话 —— 计划任务注册会被拒绝,改用「启动」文件夹实现登录自启。'
|
||||
Write-Info2 '两者效果相同;想要带「失败自动重试」的计划任务版,请用管理员身份再跑一次 -Action install。'
|
||||
Install-StartupShortcut
|
||||
}
|
||||
|
||||
if (-not (Test-BackendListening -Port (Get-Port))) {
|
||||
$task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||
if ($task) { Start-ScheduledTask -TaskName $TaskName }
|
||||
else { Start-Process -FilePath $WScript -ArgumentList ('"{0}"' -f $vbs) -WorkingDirectory $ProjectRoot | Out-Null }
|
||||
Write-Host ' 已立即启动后端,等待首轮扫描(4.5 万条目,十几秒到一分钟)……'
|
||||
$state = Wait-BackendReady -Port (Get-Port) -TimeoutSec 300
|
||||
if ($state.healthy -and $state.watching) {
|
||||
Write-Ok "后端就绪:端口 $(Get-Port),PID $($state.pid)"
|
||||
} else {
|
||||
Write-Warn2 "后端尚未就绪,稍后用 -Action status 复查;日志:$LogFile"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Uninstall-Autostart {
|
||||
Stop-Backend
|
||||
$removed = $false
|
||||
|
||||
$task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||
if ($task) {
|
||||
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
|
||||
Write-Ok "已删除计划任务 $TaskName"
|
||||
$removed = $true
|
||||
}
|
||||
if (Test-Path -LiteralPath $StartupLnk) {
|
||||
Remove-Item -LiteralPath $StartupLnk -Force
|
||||
Write-Ok "已删除「启动」文件夹里的快捷方式"
|
||||
$removed = $true
|
||||
}
|
||||
if (-not $removed) { Write-Warn2 '没有找到任何自启项,跳过。' }
|
||||
Remove-Item -LiteralPath $HiddenVbs -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
function Start-Backend {
|
||||
$port = Get-Port
|
||||
if (Test-BackendListening -Port $port) {
|
||||
Write-Ok "后端已在运行(端口 $port)"
|
||||
return $true
|
||||
}
|
||||
$other = Get-ListenerProcessId -Port $port
|
||||
if ($other -gt 0) {
|
||||
throw "端口 $port 已被 PID $other 占用,而它不是归档后端。请先处理冲突。"
|
||||
}
|
||||
|
||||
$node = Get-NodeExe
|
||||
if (-not (Test-Path -LiteralPath $ServiceJs)) { throw "找不到后端脚本:$ServiceJs" }
|
||||
if (-not (Test-Path -LiteralPath $LogDir)) { New-Item -ItemType Directory -Path $LogDir -Force | Out-Null }
|
||||
|
||||
$task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||
if ($task) {
|
||||
Start-ScheduledTask -TaskName $TaskName
|
||||
} else {
|
||||
$vbs = Write-HiddenLauncher
|
||||
Start-Process -FilePath $WScript -ArgumentList ('"{0}"' -f $vbs) -WorkingDirectory $ProjectRoot | Out-Null
|
||||
}
|
||||
|
||||
Write-Host ' 已拉起后端,等待首轮扫描(4.5 万条目大约十几秒)……'
|
||||
$state = Wait-BackendReady -Port $port -TimeoutSec 180
|
||||
if ($state.healthy -and $state.watching) {
|
||||
Write-Ok "后端就绪:端口 $port,PID $($state.pid)"
|
||||
return $true
|
||||
}
|
||||
Write-Warn2 "后端没有在预期时间内就绪。最近日志:$LogFile"
|
||||
if ($state.httpError) { Write-Warn2 " HTTP:$($state.httpError)" }
|
||||
if ($state.lastError) { Write-Warn2 " 快照:$($state.lastError)" }
|
||||
return $false
|
||||
}
|
||||
|
||||
function Stop-Backend {
|
||||
$port = Get-Port
|
||||
$task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||
if ($task) { Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue }
|
||||
|
||||
$targets = New-Object System.Collections.Generic.List[int]
|
||||
|
||||
$procId = Get-ListenerProcessId -Port $port
|
||||
if ($procId -gt 0 -and (Get-CommandLineOf -ProcessId $procId) -like '*archive-service.mjs*') {
|
||||
$targets.Add($procId)
|
||||
}
|
||||
# 兜底:按命令行找漏网的 node / 宿主
|
||||
foreach ($p in (Get-CimInstance Win32_Process -ErrorAction SilentlyContinue)) {
|
||||
$cl = [string]$p.CommandLine
|
||||
if (-not $cl) { continue }
|
||||
if ($cl -like '*archive-service.mjs*') { $targets.Add([int]$p.ProcessId) }
|
||||
elseif ($cl -like '*-Action run*' -and $cl -like '*archive-service.ps1*') { $targets.Add([int]$p.ProcessId) }
|
||||
elseif ($cl -like '*archive-service-hidden.vbs*') { $targets.Add([int]$p.ProcessId) }
|
||||
}
|
||||
|
||||
$unique = $targets | Sort-Object -Unique
|
||||
if ($unique.Count -eq 0) {
|
||||
Write-Warn2 '后端本来就没在运行。'
|
||||
return
|
||||
}
|
||||
foreach ($t in $unique) { Stop-Process -Id $t -Force -ErrorAction SilentlyContinue }
|
||||
Start-Sleep -Milliseconds 600
|
||||
if (Get-ListenerProcessId -Port $port) {
|
||||
Write-Warn2 "停止后端口 $port 仍被占用,请手动检查。"
|
||||
} else {
|
||||
Write-Ok "已停止后端(结束 $($unique.Count) 个进程)"
|
||||
}
|
||||
}
|
||||
|
||||
function Show-Status {
|
||||
$port = Get-Port
|
||||
$cfg = Get-ArchiveConfig
|
||||
$state = Get-BackendState -Port $port
|
||||
|
||||
Write-Head '归档后端(数据库)'
|
||||
Write-Item '归档根' "$($cfg.root)"
|
||||
Write-Item '端口' "$port(仅本机 127.0.0.1)"
|
||||
if ($state.listening) {
|
||||
Write-Item '进程' "PID $($state.pid) 运行中"
|
||||
} else {
|
||||
Write-Item '进程' '未运行'
|
||||
}
|
||||
if ($state.healthy) {
|
||||
Write-Item '快照' "已就绪 watching=$($state.watching)"
|
||||
if ($state.totals) {
|
||||
$gb = [math]::Round($state.totals.bytes / 1GB, 2)
|
||||
Write-Item '索引' "$($state.totals.files) 文件 / $($state.totals.dirs) 目录 / $gb GB"
|
||||
}
|
||||
Write-Item '上次扫描' "$($state.lastScan)"
|
||||
if ($state.lastError) { Write-Item '最近错误' "$($state.lastError)" }
|
||||
} elseif ($state.listening) {
|
||||
Write-Item '接口' "端口在听但 /status 不通:$($state.httpError)"
|
||||
Write-Item '提示' '令牌可能是旧的,重启一次后端即可'
|
||||
} else {
|
||||
Write-Item '接口' '不可达(后端没在跑)'
|
||||
}
|
||||
|
||||
$tokenAge = '无令牌文件'
|
||||
if (Test-Path -LiteralPath $TokenFile) {
|
||||
$t = Get-Item -LiteralPath $TokenFile
|
||||
$tokenAge = "$($t.LastWriteTime)($(($t.Length)) 字节)"
|
||||
}
|
||||
Write-Item '令牌' $tokenAge
|
||||
|
||||
Write-Head '开机自启'
|
||||
$installed = $false
|
||||
|
||||
$task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||
if ($task) {
|
||||
$info = Get-ScheduledTaskInfo -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||
Write-Item '计划任务' "$TaskName 状态=$($task.State)"
|
||||
if ($info) { Write-Item '上次结果' "$($info.LastRunTime) code=$($info.LastTaskResult)" }
|
||||
$installed = $true
|
||||
}
|
||||
|
||||
if (Test-Path -LiteralPath $StartupLnk) {
|
||||
Write-Item '启动快捷方式' $StartupLnk
|
||||
$installed = $true
|
||||
}
|
||||
|
||||
if ($installed) {
|
||||
Write-Ok '已启用:登录时后台静默启动(不弹窗口)'
|
||||
} else {
|
||||
Write-Warn2 "未安装。执行 `"$($PSCommandPath) -Action install`" 可装上。"
|
||||
}
|
||||
Write-Host ''
|
||||
}
|
||||
|
||||
function Show-Logs {
|
||||
if (-not (Test-Path -LiteralPath $LogFile)) {
|
||||
Write-Warn2 "还没有日志:$LogFile"
|
||||
return
|
||||
}
|
||||
Write-Head "后端日志尾部($LogFile)"
|
||||
Get-Content -LiteralPath $LogFile -Tail 30 -Encoding UTF8 | ForEach-Object { Write-Host " $_" }
|
||||
Write-Host ''
|
||||
}
|
||||
|
||||
function Invoke-BackendRun {
|
||||
# 计划任务的真正入口:已经在跑就直接退出,避免重复实例抢端口
|
||||
$port = Get-Port
|
||||
if (Test-BackendListening -Port $port) {
|
||||
Write-Host "归档后端已在运行(端口 $port),本次启动跳过。"
|
||||
return
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $LogDir)) { New-Item -ItemType Directory -Path $LogDir -Force | Out-Null }
|
||||
$node = Get-NodeExe
|
||||
Set-Location -LiteralPath $ProjectRoot
|
||||
& $node $ServiceJs *> $LogFile
|
||||
}
|
||||
|
||||
switch ($Action) {
|
||||
'status' { Show-Status }
|
||||
'logs' { Show-Logs }
|
||||
'install' { Install-Autostart; Write-Host ''; Show-Status }
|
||||
'uninstall' { Uninstall-Autostart }
|
||||
'start' { Start-Backend | Out-Null }
|
||||
'stop' { Stop-Backend }
|
||||
'restart' { Stop-Backend; Start-Sleep -Seconds 1; Start-Backend | Out-Null }
|
||||
'ensure' {
|
||||
# 明确给出跨进程可依赖的退出码:0 = 就绪,1 = 没能就绪
|
||||
$ok = Start-Backend
|
||||
if ($ok) { exit 0 } else { exit 1 }
|
||||
}
|
||||
'run' { Invoke-BackendRun }
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
// 依据 archive.config.json 扫描真实归档,生成三份快照:
|
||||
// content/archives.json 阵列用的四十条精选档案(简介来自 content/curated.mjs)
|
||||
// public/archive-index.json 全量检索索引(供前端检索面板使用)
|
||||
// public/archives/*.txt 每条档案的可下载摘要
|
||||
//
|
||||
// 只读归档,不修改任何归档文件。服务与 watch 模式都调用这里的 regenerate()。
|
||||
|
||||
import fs from "node:fs";
|
||||
import fsp from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL, fileURLToPath } from "node:url";
|
||||
import { curated, categories, columns } from "../content/curated.mjs";
|
||||
import { validateContent, archiveText } from "./archive-content.mjs";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.resolve(here, "..");
|
||||
|
||||
export async function loadConfig() {
|
||||
const raw = await fsp.readFile(path.join(projectRoot, "archive.config.json"), "utf8");
|
||||
const config = JSON.parse(raw);
|
||||
if (typeof config.root !== "string" || !config.root.trim())
|
||||
throw new Error("archive.config.json:root 必须是非空字符串");
|
||||
return { ...config, root: path.resolve(config.root) };
|
||||
}
|
||||
|
||||
const formatSize = (bytes) => {
|
||||
if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
|
||||
if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
|
||||
if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||
return `${bytes} B`;
|
||||
};
|
||||
|
||||
const formatDay = (ms) => {
|
||||
const d = new Date(ms);
|
||||
const p = (n) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
||||
};
|
||||
|
||||
// 递归扫描归档,返回条目数组与统计。
|
||||
// 条目为紧凑数组:[相对路径(/分隔), 'f'|'d', 字节数, mtime 毫秒]
|
||||
export function scanArchive(root, exclude = []) {
|
||||
const skip = new Set(exclude.map((name) => path.resolve(root, name)));
|
||||
const entries = [];
|
||||
const totals = { files: 0, dirs: 0, bytes: 0 };
|
||||
|
||||
const walk = (absDir, relDir) => {
|
||||
let items;
|
||||
try {
|
||||
items = fs.readdirSync(absDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const item of items) {
|
||||
const abs = path.join(absDir, item.name);
|
||||
const rel = relDir ? `${relDir}/${item.name}` : item.name;
|
||||
let stat;
|
||||
try {
|
||||
stat = fs.lstatSync(abs);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (stat.isSymbolicLink()) {
|
||||
// 只记录链接本身,不跟随,避免越出归档根目录。
|
||||
entries.push([rel, "l", 0, Math.round(stat.mtimeMs)]);
|
||||
totals.files += 1;
|
||||
continue;
|
||||
}
|
||||
if (stat.isDirectory()) {
|
||||
entries.push([rel, "d", 0, Math.round(stat.mtimeMs)]);
|
||||
totals.dirs += 1;
|
||||
if (!skip.has(abs)) walk(abs, rel);
|
||||
} else {
|
||||
entries.push([rel, "f", stat.size, Math.round(stat.mtimeMs)]);
|
||||
totals.files += 1;
|
||||
totals.bytes += stat.size;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
walk(root, "");
|
||||
return { entries, totals };
|
||||
}
|
||||
|
||||
// 取某个归档条目的实时统计,并顺带收集其内部文件的 mtime 范围。
|
||||
function statEntry(root, relPath) {
|
||||
const abs = path.join(root, relPath);
|
||||
const result = { exists: false, bytes: 0, files: 0, min: Infinity, max: 0 };
|
||||
let stat;
|
||||
try {
|
||||
stat = fs.statSync(abs);
|
||||
} catch {
|
||||
return result;
|
||||
}
|
||||
result.exists = true;
|
||||
if (stat.isFile()) {
|
||||
result.bytes = stat.size;
|
||||
result.files = 1;
|
||||
result.min = result.max = stat.mtimeMs;
|
||||
return result;
|
||||
}
|
||||
const walk = (dir) => {
|
||||
let items;
|
||||
try {
|
||||
items = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const item of items) {
|
||||
const child = path.join(dir, item.name);
|
||||
let s;
|
||||
try {
|
||||
s = fs.lstatSync(child);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (s.isDirectory()) walk(child);
|
||||
else if (s.isFile()) {
|
||||
result.files += 1;
|
||||
result.bytes += s.size;
|
||||
if (s.mtimeMs < result.min) result.min = s.mtimeMs;
|
||||
if (s.mtimeMs > result.max) result.max = s.mtimeMs;
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(abs);
|
||||
return result;
|
||||
}
|
||||
|
||||
function fill(text, stats) {
|
||||
const range =
|
||||
stats.min === Infinity
|
||||
? "无文件"
|
||||
: formatDay(stats.min) === formatDay(stats.max)
|
||||
? formatDay(stats.min)
|
||||
: `${formatDay(stats.min)} ~ ${formatDay(stats.max)}`;
|
||||
return String(text)
|
||||
.replaceAll("{size}", formatSize(stats.bytes))
|
||||
.replaceAll("{files}", String(stats.files))
|
||||
.replaceAll("{date}", range);
|
||||
}
|
||||
|
||||
export async function exportDownloads(records) {
|
||||
const downloadDir = path.join(projectRoot, "public", "archives");
|
||||
await fsp.mkdir(downloadDir, { recursive: true });
|
||||
for (const record of records) {
|
||||
await fsp.writeFile(
|
||||
path.join(downloadDir, `RHINE-LAB-${record.id}.txt`),
|
||||
archiveText(record),
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function regenerate({ quiet = false } = {}) {
|
||||
const config = await loadConfig();
|
||||
const log = (message) => {
|
||||
if (!quiet) console.log(message);
|
||||
};
|
||||
|
||||
const archivesJson = path.join(projectRoot, "content", "archives.json");
|
||||
|
||||
// 归档不在本机时(例如异地构建)沿用既有快照,只重新导出下载文件,
|
||||
// 以免 build 因缺少本地归档而整个失败。
|
||||
if (!fs.existsSync(config.root)) {
|
||||
const existing = JSON.parse(await fsp.readFile(archivesJson, "utf8"));
|
||||
validateContent(existing);
|
||||
await exportDownloads(existing.records);
|
||||
log(
|
||||
`归档根目录不可用(${config.root}),沿用既有 ${existing.records.length} 条档案快照。`,
|
||||
);
|
||||
return {
|
||||
records: existing.records,
|
||||
totals: null,
|
||||
generated: new Date().toISOString(),
|
||||
fallback: true,
|
||||
};
|
||||
}
|
||||
|
||||
// 1) 四十条精选档案:用真实统计填充占位符
|
||||
const missing = [];
|
||||
const records = curated.map((entry) => {
|
||||
const stats = statEntry(config.root, entry.path);
|
||||
if (!stats.exists) missing.push(`${entry.id} ${entry.path}`);
|
||||
const source = pathToFileURL(path.join(config.root, entry.path)).href;
|
||||
return {
|
||||
id: entry.id,
|
||||
title: entry.title,
|
||||
en: entry.en,
|
||||
category: entry.category,
|
||||
department: entry.department,
|
||||
date: entry.date,
|
||||
lead: entry.lead,
|
||||
clearance: entry.clearance,
|
||||
abstract: fill(entry.abstract, stats),
|
||||
findings: entry.findings.map((line) => fill(line, stats)),
|
||||
source,
|
||||
// 供前端与操作面板使用的真实路径信息(校验器允许附加字段)
|
||||
path: entry.path,
|
||||
stats: {
|
||||
bytes: stats.bytes,
|
||||
files: stats.files,
|
||||
modified: stats.max ? new Date(stats.max).toISOString() : null,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
if (missing.length)
|
||||
throw new Error(
|
||||
`以下精选条目在归档中不存在,已中止以免写出错误数据:\n- ${missing.join("\n- ")}`,
|
||||
);
|
||||
|
||||
const content = { categories, columns, records };
|
||||
validateContent(content); // 校验不过就不写盘
|
||||
|
||||
// 2) 全量检索索引
|
||||
const { entries, totals } = scanArchive(config.root, config.exclude);
|
||||
|
||||
const payload = {
|
||||
generated: new Date().toISOString(),
|
||||
root: config.root,
|
||||
totals,
|
||||
fields: ["path", "kind", "size", "mtime"],
|
||||
entries,
|
||||
};
|
||||
|
||||
const archivesJsonOut = path.join(projectRoot, "content", "archives.json");
|
||||
const indexJson = path.join(projectRoot, config.indexFile ?? "public/archive-index.json");
|
||||
await fsp.mkdir(path.dirname(indexJson), { recursive: true });
|
||||
await fsp.writeFile(archivesJsonOut, `${JSON.stringify(content, null, 2)}\n`, "utf8");
|
||||
await fsp.writeFile(indexJson, JSON.stringify(payload), "utf8");
|
||||
|
||||
// 3) 可下载摘要
|
||||
await exportDownloads(records);
|
||||
|
||||
log(
|
||||
`归档快照已更新:${records.length} 条精选档案,` +
|
||||
`${totals.files} 个文件 / ${totals.dirs} 个目录 / ${formatSize(totals.bytes)}`,
|
||||
);
|
||||
return { records, totals, generated: payload.generated };
|
||||
}
|
||||
|
||||
const invokedDirectly =
|
||||
process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
||||
if (invokedDirectly) {
|
||||
try {
|
||||
await regenerate();
|
||||
} catch (error) {
|
||||
console.error(`生成归档快照失败:${error.message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Rasterize the existing shared vector mark; no alternate logo or generated art.
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { labelMarkSvg } from "../src/brand.ts";
|
||||
const {default:sharp}=await import(process.env.SHARP_MODULE ? pathToFileURL(resolve(process.env.SHARP_MODULE)).href : 'sharp');
|
||||
const mark=labelMarkSvg.replace(/^<svg[^>]*>/,'').replace(/<\/svg>$/,'');
|
||||
const svg=`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><rect width="512" height="512" fill="#e8e5e1"/><g transform="translate(91 179) scale(1.0645)" color="#171713">${mark}</g></svg>`;
|
||||
await mkdir('public/icons',{recursive:true});
|
||||
await writeFile('public/icons/app-icon.svg',svg);
|
||||
for(const [name,size] of [['apple-touch-icon',180],['icon-192',192],['icon-512',512],['icon-maskable-512',512]])
|
||||
await sharp(Buffer.from(svg)).resize(size,size).png().toFile(`public/icons/${name}.png`);
|
||||
console.log('Shared Rhine Lab mark exported to four home-screen icons.');
|
||||
@@ -0,0 +1,19 @@
|
||||
import { readdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { createHash } from "node:crypto";
|
||||
import { resolve } from "node:path";
|
||||
const root=resolve('dist');
|
||||
const all=await readdir(root,{recursive:true});
|
||||
const files=all.map(path=>path.replaceAll('\\','/')).filter(path=>
|
||||
path==='index.html'||path==='manifest.webmanifest'||path==='favicon.svg'||
|
||||
/^(assets|icons|archives|licenses)\/[^/]+\.[^/]+$/.test(path)||
|
||||
/^fonts\/.*\.(woff2|pdf|txt|json|md)$/.test(path)||
|
||||
/^audio\/(atmosphere|motif|pulse)\.ogg$/.test(path)
|
||||
).filter(path=>!/^assets\/archive-(cassette|assembly)\.glb$/.test(path)).sort();
|
||||
if(!files.some(path=>/^assets\/index-.*\.js$/.test(path)))throw Error('Build the application before generating the offline cache.');
|
||||
const worker=await readFile('scripts/pwa-worker.js','utf8');
|
||||
const hash=createHash('sha256').update(worker);let bytes=0;
|
||||
for(const file of files){const content=await readFile(resolve(root,file));hash.update(file).update(content);bytes+=content.length}
|
||||
const version=hash.digest('hex').slice(0,16);
|
||||
await writeFile(resolve(root,'sw.js'),worker.replace('__CACHE_VERSION__',JSON.stringify(version)).replace('__PRECACHE_FILES__',JSON.stringify(files)));
|
||||
await writeFile(resolve(root,'pwa-build.json'),JSON.stringify({version,bytes,files},null,2));
|
||||
console.log(`Offline release ${version}: ${files.length} files, ${(bytes/1024/1024).toFixed(1)} MiB.`);
|
||||
@@ -0,0 +1,30 @@
|
||||
import { copyFile, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { resolve, relative, sep } from "node:path";
|
||||
|
||||
const root = resolve("release/wallpaper");
|
||||
const project = JSON.parse(await readFile("wallpaper/project.json", "utf8"));
|
||||
if (project.general?.supportsaudioprocessing !== true || Object.hasOwn(project, "supportsaudioprocessing"))
|
||||
throw new Error("Wallpaper audio requires general.supportsaudioprocessing=true");
|
||||
// Only trim generated output, never the source public directory.
|
||||
for (const name of ["update.html", "update.js", "manifest.webmanifest", "audio/observatory-preview.mp3", "assets/archive-cassette.glb", "assets/archive-assembly.glb"]) {
|
||||
const target = resolve(root, name);
|
||||
if (!target.startsWith(root + sep)) throw new Error("Output path escapes wallpaper directory");
|
||||
await rm(target, { force: true });
|
||||
}
|
||||
await copyFile("wallpaper/project.json", resolve(root, "project.json"));
|
||||
await copyFile("docs/media/archive.jpg", resolve(root, "preview.jpg"));
|
||||
if (project.preview === "preview.gif") await copyFile("wallpaper/preview.gif", resolve(root, "preview.gif"));
|
||||
await copyFile("LICENSE", resolve(root, "LICENSE"));
|
||||
const html = await readFile(resolve(root, "index.html"), "utf8");
|
||||
if (/\b(?:src|href)=["']\//.test(html)) throw new Error("Wallpaper HTML contains root-relative URLs");
|
||||
const files = [];
|
||||
async function walk(dir) {
|
||||
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
||||
const path = resolve(dir, entry.name);
|
||||
if (entry.isDirectory()) await walk(path);
|
||||
else files.push({ path: relative(root, path).replaceAll("\\", "/"), bytes: (await stat(path)).size });
|
||||
}
|
||||
}
|
||||
await walk(root);
|
||||
await writeFile(resolve(root, "build-files.json"), JSON.stringify(files, null, 2) + "\n");
|
||||
console.log(`Wallpaper ready: ${root} (${files.length} files, ${(files.reduce((n, f) => n + f.bytes, 0) / 1048576).toFixed(1)} MiB)`);
|
||||
@@ -0,0 +1,250 @@
|
||||
// Run against a local dev server. Requires Playwright, Chrome and FFmpeg.
|
||||
// PLAYWRIGHT_MODULE may point to an existing Playwright index.mjs.
|
||||
// FFMPEG may point to an existing ffmpeg executable.
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const { chromium } = await import(
|
||||
process.env.PLAYWRIGHT_MODULE
|
||||
? pathToFileURL(resolve(process.env.PLAYWRIGHT_MODULE)).href
|
||||
: "playwright"
|
||||
);
|
||||
const ffmpeg = process.env.FFMPEG || "ffmpeg";
|
||||
const base = process.env.CAPTURE_URL || "http://127.0.0.1:5186";
|
||||
const output = resolve("docs/media");
|
||||
const temporary = resolve(".tools/readme-capture");
|
||||
await mkdir(output, { recursive: true });
|
||||
await mkdir(temporary, { recursive: true });
|
||||
const browser = await chromium.launch({
|
||||
channel: "chrome",
|
||||
headless: true,
|
||||
args:
|
||||
process.platform === "win32"
|
||||
? ["--use-angle=d3d11", "--enable-gpu", "--ignore-gpu-blocklist"]
|
||||
: ["--enable-gpu"],
|
||||
});
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1600, height: 900 },
|
||||
deviceScaleFactor: 1,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const errors = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") errors.push(message.text());
|
||||
});
|
||||
const session = await context.newCDPSession(page);
|
||||
const manifest = {
|
||||
commit: spawnSync("git", ["rev-parse", "HEAD"], {
|
||||
encoding: "utf8",
|
||||
}).stdout.trim(),
|
||||
capturedAt: new Date().toISOString(),
|
||||
viewport: "1600x900",
|
||||
quality: "original",
|
||||
gifs: [],
|
||||
screenshots: [],
|
||||
errors,
|
||||
};
|
||||
const wait = (ms) => page.waitForTimeout(ms);
|
||||
const click = (selector) => page.locator(selector).click();
|
||||
async function shot(name) {
|
||||
await page.mouse.move(1580, 880);
|
||||
await page.screenshot({
|
||||
path: resolve(output, `${name}.jpg`),
|
||||
type: "jpeg",
|
||||
quality: 93,
|
||||
});
|
||||
manifest.screenshots.push(name);
|
||||
}
|
||||
function encode(args) {
|
||||
const result = spawnSync(
|
||||
ffmpeg,
|
||||
["-y", "-hide_banner", "-loglevel", "error", ...args],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
if (result.status !== 0)
|
||||
throw new Error(result.stderr || `FFmpeg failed: ${result.error}`);
|
||||
}
|
||||
async function record(name, actions, width = 800) {
|
||||
const fps = name === "browse" ? 8 : 12;
|
||||
if (name === "browse") width = 640;
|
||||
const folder = resolve(temporary, `${name}-${Date.now()}`);
|
||||
await mkdir(folder, { recursive: true });
|
||||
const frames = [];
|
||||
const writes = [];
|
||||
const onFrame = (event) => {
|
||||
const filename = `frame-${String(frames.length).padStart(5, "0")}.jpg`;
|
||||
frames.push({ filename, timestamp: event.metadata.timestamp });
|
||||
writes.push(
|
||||
writeFile(resolve(folder, filename), Buffer.from(event.data, "base64")),
|
||||
);
|
||||
void session
|
||||
.send("Page.screencastFrameAck", { sessionId: event.sessionId })
|
||||
.catch(() => {});
|
||||
};
|
||||
session.on("Page.screencastFrame", onFrame);
|
||||
await session.send("Page.startScreencast", {
|
||||
format: "jpeg",
|
||||
quality: 92,
|
||||
maxWidth: 1600,
|
||||
maxHeight: 900,
|
||||
everyNthFrame: 3,
|
||||
});
|
||||
try {
|
||||
await wait(250);
|
||||
await actions();
|
||||
} finally {
|
||||
await session.send("Page.stopScreencast");
|
||||
session.off("Page.screencastFrame", onFrame);
|
||||
await Promise.all(writes);
|
||||
}
|
||||
if (frames.length < 10) throw new Error(`Too few frames for ${name}`);
|
||||
const intervals = frames
|
||||
.slice(1)
|
||||
.map((frame, i) => frame.timestamp - frames[i].timestamp);
|
||||
const duration = frames.at(-1).timestamp - frames[0].timestamp;
|
||||
await writeFile(
|
||||
resolve(folder, "frames.txt"),
|
||||
frames
|
||||
.map(
|
||||
(frame, i) =>
|
||||
`file '${frame.filename}'\nduration ${intervals[i] || 1 / 12}\n`,
|
||||
)
|
||||
.join("") + `file '${frames.at(-1).filename}'\n`,
|
||||
);
|
||||
encode([
|
||||
"-f",
|
||||
"concat",
|
||||
"-safe",
|
||||
"0",
|
||||
"-i",
|
||||
resolve(folder, "frames.txt"),
|
||||
"-vf",
|
||||
`fps=${fps},scale=${width}:-2:flags=lanczos,split[a][b];[a]palettegen=max_colors=96:stats_mode=full[p];[b][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle`,
|
||||
"-loop",
|
||||
"0",
|
||||
resolve(output, `${name}.gif`),
|
||||
]);
|
||||
manifest.gifs.push({
|
||||
name,
|
||||
frames: frames.length,
|
||||
duration,
|
||||
width,
|
||||
fps,
|
||||
averageCaptureFps: frames.length / duration,
|
||||
maximumFrameGap: Math.max(...intervals),
|
||||
});
|
||||
console.log(
|
||||
`Captured ${name}: ${duration.toFixed(2)}s, ${frames.length} source frames`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
await page.goto(`${base}/?scene=archive`);
|
||||
await page.waitForFunction(
|
||||
() => window.rhine?.stats().ready && !document.querySelector("#loading"),
|
||||
null,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
await wait(6000);
|
||||
await shot("archive");
|
||||
await record("browse", async () => {
|
||||
for (const key of [
|
||||
"ArrowDown",
|
||||
"ArrowDown",
|
||||
"ArrowRight",
|
||||
"ArrowDown",
|
||||
"ArrowRight",
|
||||
"ArrowLeft",
|
||||
"ArrowLeft",
|
||||
]) {
|
||||
await page.keyboard.press(key);
|
||||
await wait(700);
|
||||
}
|
||||
await wait(1000);
|
||||
});
|
||||
await page.evaluate(() => window.rhine.select(0));
|
||||
await wait(1600);
|
||||
await record(
|
||||
"decryption",
|
||||
async () => {
|
||||
await page.keyboard.press("Enter");
|
||||
await wait(7500);
|
||||
},
|
||||
960,
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => window.rhine.stats().decryption.phase === "clear",
|
||||
);
|
||||
await shot("detail");
|
||||
await click('[data-tab="notes"]');
|
||||
await wait(450);
|
||||
await shot("research");
|
||||
await click('[data-action="model-viewer"]');
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector('[data-viewer="explode"]')?.disabled === false,
|
||||
);
|
||||
await wait(1700);
|
||||
await shot("viewer-clear");
|
||||
await record("glass-motion", async () => {
|
||||
await click('[data-viewer="frosted"]');
|
||||
await wait(1700);
|
||||
await shot("viewer-frosted");
|
||||
await click('[data-viewer="clear"]');
|
||||
await wait(1700);
|
||||
});
|
||||
await record("assembly-motion", async () => {
|
||||
await click('[data-viewer="explode"]');
|
||||
await wait(1800);
|
||||
await shot("assembly");
|
||||
await page.mouse.move(850, 460);
|
||||
await page.mouse.down();
|
||||
for (let i = 1; i <= 30; i++) {
|
||||
await page.mouse.move(850 + i * 6, 460 + i);
|
||||
await wait(25);
|
||||
}
|
||||
await page.mouse.up();
|
||||
await wait(600);
|
||||
await click('[data-viewer="reset"]');
|
||||
await wait(850);
|
||||
await click('[data-viewer="assemble"]');
|
||||
await wait(1800);
|
||||
});
|
||||
await click('[data-viewer="close"]');
|
||||
await wait(450);
|
||||
await page.keyboard.press("Escape");
|
||||
await wait(1800);
|
||||
await click('[data-action="search"]');
|
||||
await wait(400);
|
||||
await page.locator("#archive-search").fill("莱茵");
|
||||
await wait(500);
|
||||
await shot("search");
|
||||
await page.keyboard.press("Escape");
|
||||
await wait(350);
|
||||
await click('[data-action="settings"]');
|
||||
await wait(400);
|
||||
await shot("settings");
|
||||
await page.keyboard.press("Escape");
|
||||
await wait(350);
|
||||
await record("boot-motion", async () => {
|
||||
await click('[data-action="replay"]');
|
||||
await wait(6500);
|
||||
await shot("boot");
|
||||
await wait(13000);
|
||||
});
|
||||
manifest.stats = await page.evaluate(() => window.rhine.stats());
|
||||
await writeFile(
|
||||
resolve(temporary, "manifest.json"),
|
||||
JSON.stringify(manifest, null, 2),
|
||||
);
|
||||
if (errors.length) throw new Error(errors.join("\n"));
|
||||
const { stats, ...publicManifest } = manifest;
|
||||
await writeFile(
|
||||
resolve(output, "capture.json"),
|
||||
JSON.stringify(publicManifest, null, 2) + "\n",
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import assert from "node:assert/strict";
|
||||
import * as THREE from "three";
|
||||
import { CardAppearance } from "../src/appearance.ts";
|
||||
|
||||
const appearance = new CardAppearance();
|
||||
const high = new THREE.MeshPhysicalMaterial({
|
||||
color: "#fffdfa",
|
||||
transmission: 0.9,
|
||||
roughness: 0.21,
|
||||
attenuationColor: "#eee6df",
|
||||
attenuationDistance: 2,
|
||||
});
|
||||
const low = new THREE.MeshPhysicalMaterial({
|
||||
color: "#fff7ed",
|
||||
transmission: 0.78,
|
||||
roughness: 0.28,
|
||||
attenuationColor: "#d4c7b4",
|
||||
attenuationDistance: 1.2,
|
||||
});
|
||||
appearance.register("Frosted_Polymer", high, low);
|
||||
const group = new THREE.Group();
|
||||
const body = new THREE.Mesh(new THREE.BoxGeometry(), high);
|
||||
body.userData.surface = "Frosted_Polymer";
|
||||
group.add(body);
|
||||
appearance.prepare(group);
|
||||
appearance.apply(group, 0);
|
||||
assert.ok(body.material.color.equals(low.color));
|
||||
assert.equal(body.material.transmission, low.transmission);
|
||||
assert.ok(body.material.attenuationColor.equals(low.attenuationColor));
|
||||
assert.equal(body.material.attenuationDistance, low.attenuationDistance);
|
||||
let last = body.material.transmission;
|
||||
for (let i = 1; i <= 100; i++) {
|
||||
appearance.apply(group, i / 100);
|
||||
assert.ok(
|
||||
Math.abs(body.material.transmission - last) < 0.00121,
|
||||
"Surface changes continuously",
|
||||
);
|
||||
last = body.material.transmission;
|
||||
assert.ok(
|
||||
body.material.attenuationColor.equals(
|
||||
low.attenuationColor.clone().lerp(high.attenuationColor, i / 100),
|
||||
),
|
||||
);
|
||||
assert.equal(
|
||||
body.material.attenuationDistance,
|
||||
THREE.MathUtils.lerp(1.2, 2, i / 100),
|
||||
);
|
||||
}
|
||||
assert.ok(body.material.color.equals(high.color));
|
||||
assert.equal(body.material.transmission, high.transmission);
|
||||
assert.ok(body.material.attenuationColor.equals(high.attenuationColor));
|
||||
|
||||
// Opaque glTF surfaces may be Standard materials without absorption properties.
|
||||
const opaque = new THREE.MeshStandardMaterial({ color: "#c7beb6" });
|
||||
appearance.register("Opaque", opaque, opaque.clone());
|
||||
const solid = new THREE.Mesh(new THREE.BoxGeometry(), opaque);
|
||||
solid.userData.surface = "Opaque";
|
||||
const solids = new THREE.Group();
|
||||
solids.add(solid);
|
||||
appearance.prepare(solids);
|
||||
assert.doesNotThrow(() => appearance.apply(solids, 0.5));
|
||||
|
||||
// Default unbounded absorption must not turn into NaN during interpolation.
|
||||
const clear = new THREE.MeshPhysicalMaterial();
|
||||
appearance.register("Clear", clear, clear.clone());
|
||||
const glass = new THREE.Mesh(new THREE.BoxGeometry(), clear);
|
||||
glass.userData.surface = "Clear";
|
||||
solids.add(glass);
|
||||
appearance.prepare(solids);
|
||||
appearance.apply(solids, 0.5);
|
||||
assert.equal(glass.material.attenuationDistance, Infinity);
|
||||
appearance.dispose(solids);
|
||||
|
||||
// A file selected again while descending must keep its own material state.
|
||||
const returning = group.clone(true);
|
||||
appearance.prepare(returning);
|
||||
appearance.apply(returning, 0.37);
|
||||
appearance.apply(group, 0.81);
|
||||
assert.notEqual(returning.children[0].material, body.material);
|
||||
assert.equal(returning.children[0].userData.appearance.value, 0.37);
|
||||
assert.equal(body.userData.appearance.value, 0.81);
|
||||
appearance.apply(returning, 1e-8);
|
||||
assert.ok(
|
||||
Math.abs(returning.children[0].material.transmission - low.transmission) <
|
||||
1e-8,
|
||||
"Array handoff has the same surface",
|
||||
);
|
||||
|
||||
const shader = {
|
||||
uniforms: {},
|
||||
vertexShader: "#include <begin_vertex>",
|
||||
fragmentShader: "#include <color_fragment>\n#include <roughnessmap_fragment>",
|
||||
};
|
||||
body.material.onBeforeCompile(shader, null);
|
||||
assert.equal(shader.uniforms.archiveQuality, body.userData.appearance);
|
||||
assert.ok(shader.fragmentShader.includes("roughnessFactor = mix(0.28"));
|
||||
appearance.dispose(returning);
|
||||
assert.ok(body.material.color.r > 0);
|
||||
console.log(
|
||||
"Appearance interpolation, independent returning materials, shader uniform and array handoff: passed",
|
||||
);
|
||||
@@ -0,0 +1,208 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
const { chromium } = await import(
|
||||
process.env.PLAYWRIGHT_MODULE
|
||||
? pathToFileURL(resolve(process.env.PLAYWRIGHT_MODULE)).href
|
||||
: "playwright"
|
||||
);
|
||||
const browser = await chromium.launch({
|
||||
channel: "chrome",
|
||||
headless: true,
|
||||
args: ["--use-angle=d3d11", "--enable-gpu", "--ignore-gpu-blocklist"],
|
||||
});
|
||||
const report = [];
|
||||
await mkdir(".tools/array-input", { recursive: true });
|
||||
const stats = (page) => page.evaluate(() => rhine.stats());
|
||||
try {
|
||||
for (const [width, height, mobile] of [
|
||||
[1920, 1080, false],
|
||||
[390, 844, true],
|
||||
[844, 390, true],
|
||||
].filter(
|
||||
([w, h]) =>
|
||||
!process.env.REVIEW_CASES ||
|
||||
process.env.REVIEW_CASES.split(",").includes(`${w}x${h}`),
|
||||
)) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width, height },
|
||||
hasTouch: mobile,
|
||||
isMobile: mobile,
|
||||
});
|
||||
const page = await context.newPage(),
|
||||
errors = [];
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
await page.goto(
|
||||
`${process.env.REVIEW_URL || "http://127.0.0.1:5204"}/?scene=archive`,
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => window.rhine?.stats().extraction >= 0.399,
|
||||
null,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await page.locator('[data-action="next"]').click();
|
||||
await page.waitForTimeout(2200);
|
||||
const cdp = mobile ? await context.newCDPSession(page) : null;
|
||||
const landscape = mobile && width > height;
|
||||
const x = width * (landscape ? 0.4 : 0.65),
|
||||
y = height * (landscape ? 0.52 : 0.3);
|
||||
const down = async () =>
|
||||
mobile
|
||||
? cdp.send("Input.dispatchTouchEvent", {
|
||||
type: "touchStart",
|
||||
touchPoints: [{ x, y, id: 1 }],
|
||||
})
|
||||
: (await page.mouse.move(x, y), page.mouse.down());
|
||||
const move = async (px, py) =>
|
||||
mobile
|
||||
? cdp.send("Input.dispatchTouchEvent", {
|
||||
type: "touchMove",
|
||||
touchPoints: [{ x: px, y: py, id: 1 }],
|
||||
})
|
||||
: page.mouse.move(px, py);
|
||||
const up = async () =>
|
||||
mobile
|
||||
? cdp.send("Input.dispatchTouchEvent", {
|
||||
type: "touchEnd",
|
||||
touchPoints: [],
|
||||
})
|
||||
: page.mouse.up();
|
||||
const results = [];
|
||||
for (const axis of ["lane", "row"])
|
||||
for (const sign of [1, -1]) {
|
||||
const before = await stats(page);
|
||||
const vector = before.dragProjection[axis];
|
||||
assert.ok(
|
||||
Math.abs(vector.x) > 1 && Math.abs(vector.y) > 1,
|
||||
"The actual camera produces diagonal axes",
|
||||
);
|
||||
const amount = (axis === "lane" ? 0.8 : 1.8) * sign;
|
||||
await down();
|
||||
const captured = await stats(page);
|
||||
assert.ok(
|
||||
captured.holdingArchive,
|
||||
"Gesture starts in the canvas, clear of the document controls",
|
||||
);
|
||||
// Use the camera at pointer-down; mouse parallax can slightly change it.
|
||||
const direction = captured.dragProjection[axis];
|
||||
await move(x + direction.x * amount, y + direction.y * amount);
|
||||
await page.waitForTimeout(160);
|
||||
const during = await stats(page);
|
||||
assert.equal(
|
||||
during.dragMapping,
|
||||
"free",
|
||||
`${width}x${height}: ${axis} uses the camera projection`,
|
||||
);
|
||||
assert.ok(during.dragTrack);
|
||||
const track = axis === "lane" ? "columnCamera" : "rail",
|
||||
spacing = axis === "lane" ? 5.2 : -0.62;
|
||||
assert.ok(
|
||||
Math.abs((during[track] - captured[track]) / spacing - amount) < 0.04,
|
||||
"Projected travel follows the requested physical distance",
|
||||
);
|
||||
assert.equal(
|
||||
during.selectedCell[axis],
|
||||
before.selectedCell[axis] + Math.round(amount),
|
||||
);
|
||||
if (axis === "row")
|
||||
assert.equal(
|
||||
during.selectedCell.lane,
|
||||
before.selectedCell.lane,
|
||||
"Depth drag keeps its column",
|
||||
);
|
||||
await up();
|
||||
await page.waitForFunction(() => !rhine.stats().archiveMomentum, null, {
|
||||
timeout: 12000,
|
||||
});
|
||||
results.push({ axis, sign, direction, mapping: during.dragMapping });
|
||||
}
|
||||
// One held gesture moves freely in screen space and turns without relocking.
|
||||
await down();
|
||||
const anchor = await stats(page),
|
||||
basis = anchor.dragProjection;
|
||||
const paths = [
|
||||
[45, 0],
|
||||
[45, 50],
|
||||
[-35, 50],
|
||||
[-35, -30],
|
||||
[0, 0],
|
||||
];
|
||||
for (const [dx, dy] of paths) {
|
||||
await move(x + dx, y + dy);
|
||||
await page.waitForTimeout(100);
|
||||
const state = await stats(page);
|
||||
const lane = (state.columnCamera - anchor.columnCamera) / 5.2;
|
||||
const row = (state.rail - anchor.rail) / -0.62;
|
||||
assert.ok(
|
||||
Math.abs(lane * basis.lane.x + row * basis.row.x - dx) < 2,
|
||||
"Screen X follows the pointer",
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(lane * basis.lane.y + row * basis.row.y - dy) < 2,
|
||||
"Screen Y follows the pointer",
|
||||
);
|
||||
assert.equal(state.dragMapping, "free");
|
||||
}
|
||||
await up();
|
||||
await page.waitForFunction(() => !rhine.stats().archiveMomentum);
|
||||
results.push({ screenPath: paths, checks: "free turns passed" });
|
||||
if (!mobile) {
|
||||
await down();
|
||||
const releaseBasis = (await stats(page)).dragProjection;
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
await move(
|
||||
x + ((releaseBasis.lane.x * 0.4 + releaseBasis.row.x * 1.8) * i) / 4,
|
||||
y + ((releaseBasis.lane.y * 0.4 + releaseBasis.row.y * 1.8) * i) / 4,
|
||||
);
|
||||
await page.waitForTimeout(8);
|
||||
}
|
||||
await up();
|
||||
const release = (await stats(page)).archiveMomentum;
|
||||
assert.equal(release?.phase, "coasting");
|
||||
assert.ok(release.velocity.lane > 0 && release.velocity.row > 0);
|
||||
await page.waitForTimeout(250);
|
||||
const coast = (await stats(page)).archiveMomentum;
|
||||
assert.equal(coast?.phase, "coasting");
|
||||
assert.ok(
|
||||
coast.value.lane > release.value.lane &&
|
||||
coast.value.row > release.value.row,
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
coast.velocity.lane / coast.velocity.row -
|
||||
release.velocity.lane / release.velocity.row,
|
||||
) < 0.001,
|
||||
);
|
||||
await down();
|
||||
const caught = await stats(page);
|
||||
assert.equal(caught.archiveMomentum, null);
|
||||
await page.waitForTimeout(120);
|
||||
const held = await stats(page);
|
||||
assert.equal(held.columnCamera, caught.columnCamera);
|
||||
assert.equal(held.rail, caught.rail);
|
||||
await up();
|
||||
await page.waitForTimeout(1500);
|
||||
results.push({
|
||||
checks: "free coast and two-track catch passed",
|
||||
release,
|
||||
});
|
||||
}
|
||||
await page.screenshot({
|
||||
path: resolve(`.tools/array-input/diagonal-${width}x${height}.png`),
|
||||
});
|
||||
assert.deepEqual(errors, []);
|
||||
report.push({ width, height, mobile, results, checks: "passed" });
|
||||
console.log(
|
||||
`${width}x${height}: projected axes, free screen paths and turns passed`,
|
||||
);
|
||||
await context.close();
|
||||
}
|
||||
} finally {
|
||||
await mkdir(".tools/array-input", { recursive: true });
|
||||
await writeFile(
|
||||
".tools/array-input/diagonal.json",
|
||||
JSON.stringify(report, null, 2),
|
||||
);
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
ArchiveDrag,
|
||||
ArchiveMomentum,
|
||||
ArchivePlaneMomentum,
|
||||
} from "../src/archive-drag.ts";
|
||||
const projection = { lane: { x: -240, y: 120 }, row: { x: -50, y: -15 } };
|
||||
const close = (a, b, message) => assert.ok(Math.abs(a - b) < 1e-9, message);
|
||||
const drag = new ArchiveDrag();
|
||||
drag.start(500, 500, projection);
|
||||
drag.move(504, 502, 10);
|
||||
assert.equal(drag.active, false);
|
||||
assert.equal(drag.moved, false);
|
||||
// Every screen direction decomposes back into the same displayed displacement.
|
||||
for (const [dx, dy] of [
|
||||
[-120, 60],
|
||||
[-100, -30],
|
||||
[100, 0],
|
||||
[0, -100],
|
||||
[50, 90],
|
||||
[-30, 20],
|
||||
]) {
|
||||
drag.move(500 + dx, 500 + dy, 40);
|
||||
assert.equal(drag.active, true);
|
||||
close(
|
||||
drag.value.lane * projection.lane.x + drag.value.row * projection.row.x,
|
||||
dx,
|
||||
);
|
||||
close(
|
||||
drag.value.lane * projection.lane.y + drag.value.row * projection.row.y,
|
||||
dy,
|
||||
);
|
||||
}
|
||||
// A single held gesture can turn from one track into the other.
|
||||
drag.move(380, 560, 60);
|
||||
close(drag.value.lane, 0.5);
|
||||
close(drag.value.row, 0);
|
||||
drag.move(400, 470, 80);
|
||||
close(drag.value.lane, 0);
|
||||
close(drag.value.row, 2);
|
||||
drag.move(500, 500, 100);
|
||||
assert.equal(drag.moved, true, "Out-and-back cannot become a click");
|
||||
close(drag.value.lane, 0);
|
||||
close(drag.value.row, 0);
|
||||
assert.deepEqual(drag.releaseVelocity(200, false), { lane: 0, row: 0 });
|
||||
assert.deepEqual(drag.releaseVelocity(100, true), { lane: 0, row: 0 });
|
||||
const alternate = { lane: { x: 180, y: 100 }, row: { x: 40, y: -12 } };
|
||||
drag.start(500, 500, alternate);
|
||||
alternate.lane.x = 0;
|
||||
drag.move(590, 550, 40);
|
||||
close(drag.value.lane, 0.5, "Freeze camera mapping for the gesture");
|
||||
close(drag.value.row, 0);
|
||||
for (const invalid of [
|
||||
{ lane: { x: 1, y: 1 }, row: { x: 2, y: 2 } },
|
||||
{ lane: { x: NaN, y: 1 }, row: { x: 0, y: 1 } },
|
||||
]) {
|
||||
drag.start(0, 0, invalid);
|
||||
drag.move(100, 100, 50);
|
||||
assert.equal(
|
||||
drag.active,
|
||||
false,
|
||||
"Degenerate projection must not generate unbounded motion",
|
||||
);
|
||||
}
|
||||
// Reverse the full screen path: momentum follows the final direction.
|
||||
drag.start(0, 0, projection);
|
||||
drag.move(-100, -30, 20);
|
||||
drag.move(-100, -30, 30);
|
||||
drag.move(-75, -22.5, 45);
|
||||
assert.ok(drag.releaseVelocity(45, false).row < 0);
|
||||
console.log(
|
||||
"Free projection, direction changes, jitter, reversal and reduced motion passed.",
|
||||
);
|
||||
const fling = (duration) => {
|
||||
const input = new ArchiveDrag();
|
||||
input.start(0, 0, projection);
|
||||
for (let i = 1; i <= 12; i++)
|
||||
input.move(
|
||||
(projection.row.x * 1.6 * i) / 12,
|
||||
(projection.row.y * 1.6 * i) / 12,
|
||||
(duration * i) / 12,
|
||||
);
|
||||
return new ArchiveMomentum(
|
||||
input.value.row,
|
||||
input.releaseVelocity(duration, false).row,
|
||||
);
|
||||
};
|
||||
const fast = fling(80),
|
||||
slow = fling(800);
|
||||
assert.ok(fast.velocity > slow.velocity * 8);
|
||||
assert.equal(fast.value, slow.value);
|
||||
const fastStart = fast.value;
|
||||
const fastVelocity = fast.velocity;
|
||||
fast.step(1 / 60);
|
||||
assert.ok(
|
||||
fast.value > fastStart,
|
||||
"The first released frame continues from the pointer",
|
||||
);
|
||||
assert.ok(fast.velocity < fastVelocity && fast.velocity > fastVelocity * 0.9);
|
||||
for (let i = 0; i < 600; i++) {
|
||||
fast.step(1 / 120);
|
||||
slow.step(1 / 120);
|
||||
}
|
||||
assert.equal(fast.phase, "idle");
|
||||
assert.equal(slow.phase, "idle");
|
||||
assert.ok(fast.value > slow.value + 5, "A fast flick crosses many more files");
|
||||
assert.ok(
|
||||
fast.value - fastStart > 3,
|
||||
"Travel is no longer limited to three files",
|
||||
);
|
||||
assert.equal(fast.value, Math.round(fast.value));
|
||||
|
||||
const simulate = (fps) => {
|
||||
const motion = new ArchiveMomentum(12.3, 24);
|
||||
for (let i = 0; i < fps; i++) motion.step(1 / fps);
|
||||
return motion;
|
||||
};
|
||||
const at30 = simulate(30),
|
||||
at60 = simulate(60),
|
||||
at120 = simulate(120);
|
||||
assert.ok(Math.abs(at30.value - at120.value) < 1e-10);
|
||||
assert.ok(Math.abs(at60.velocity - at120.velocity) < 1e-10);
|
||||
const backward = new ArchiveMomentum(-3.2, -35);
|
||||
for (let i = 0; i < 600; i++) backward.step(1 / 120);
|
||||
assert.equal(backward.phase, "idle");
|
||||
assert.ok(backward.value < -15, "Negative positions continue through the loop");
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
fastRest: fast.value,
|
||||
slowRest: slow.value,
|
||||
frameRateIndependent: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const plane = new ArchivePlaneMomentum(
|
||||
{ lane: 2.3, row: 12.8 },
|
||||
{ lane: 2, row: 24 },
|
||||
);
|
||||
let previous = plane.value;
|
||||
for (let i = 0; i < 90; i++) {
|
||||
if (plane.phase !== "coasting") break;
|
||||
plane.step(1 / 60);
|
||||
close(
|
||||
(plane.value.lane - previous.lane) * 12,
|
||||
plane.value.row - previous.row,
|
||||
"Both tracks keep the screen direction, even when the smaller component is slow",
|
||||
);
|
||||
previous = plane.value;
|
||||
}
|
||||
for (let i = 0; i < 600; i++) plane.step(1 / 120);
|
||||
assert.equal(plane.phase, "idle");
|
||||
assert.equal(plane.value.lane, Math.round(plane.value.lane));
|
||||
assert.equal(plane.value.row, Math.round(plane.value.row));
|
||||
console.log("Two-dimensional coasting and final grid snap passed.");
|
||||
@@ -0,0 +1,205 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
const { chromium } = await import(
|
||||
process.env.PLAYWRIGHT_MODULE
|
||||
? pathToFileURL(resolve(process.env.PLAYWRIGHT_MODULE)).href
|
||||
: "playwright"
|
||||
);
|
||||
const browser = await chromium.launch({
|
||||
channel: "chrome",
|
||||
headless: true,
|
||||
args: ["--use-angle=d3d11", "--enable-gpu", "--ignore-gpu-blocklist"],
|
||||
});
|
||||
const report = [];
|
||||
const stats = (page) => page.evaluate(() => window.rhine.stats());
|
||||
const rest = (page) =>
|
||||
page.waitForFunction(() => !window.rhine.stats().archiveMomentum, null, {
|
||||
timeout: 12000,
|
||||
});
|
||||
try {
|
||||
for (const mobile of [false, true].filter(
|
||||
(mobile) =>
|
||||
!process.env.REVIEW_CASES ||
|
||||
process.env.REVIEW_CASES === (mobile ? "mobile" : "desktop"),
|
||||
)) {
|
||||
const width = mobile ? 390 : 1920,
|
||||
height = mobile ? 844 : 1080;
|
||||
const context = await browser.newContext({
|
||||
viewport: { width, height },
|
||||
hasTouch: mobile,
|
||||
isMobile: mobile,
|
||||
});
|
||||
const page = await context.newPage(),
|
||||
errors = [];
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
await page.goto(
|
||||
`${process.env.REVIEW_URL || "http://127.0.0.1:5204"}/?scene=archive`,
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => window.rhine?.stats().extraction >= 0.399,
|
||||
null,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await page.locator('[data-action="next"]').click();
|
||||
await page.waitForTimeout(2200);
|
||||
const cdp = mobile ? await context.newCDPSession(page) : null;
|
||||
const x = width * 0.6,
|
||||
y = height * 0.38;
|
||||
const down = async () =>
|
||||
mobile
|
||||
? cdp.send("Input.dispatchTouchEvent", {
|
||||
type: "touchStart",
|
||||
touchPoints: [{ x, y, id: 1 }],
|
||||
})
|
||||
: (await page.mouse.move(x, y), page.mouse.down());
|
||||
const move = async (px, py) =>
|
||||
mobile
|
||||
? cdp.send("Input.dispatchTouchEvent", {
|
||||
type: "touchMove",
|
||||
touchPoints: [{ x: px, y: py, id: 1 }],
|
||||
})
|
||||
: page.mouse.move(px, py);
|
||||
const up = async () =>
|
||||
mobile
|
||||
? cdp.send("Input.dispatchTouchEvent", {
|
||||
type: "touchEnd",
|
||||
touchPoints: [],
|
||||
})
|
||||
: page.mouse.up();
|
||||
const fling = async (delay, axis = "row") => {
|
||||
await down();
|
||||
const vector = (await stats(page)).dragProjection[axis];
|
||||
const travel = axis === "row" ? 3.2 : 0.8;
|
||||
const segments = delay < 30 ? 4 : 8;
|
||||
for (let i = 1; i <= segments; i++) {
|
||||
await page.waitForTimeout(delay);
|
||||
await move(
|
||||
x + (vector.x * travel * i) / segments,
|
||||
y + (vector.y * travel * i) / segments,
|
||||
);
|
||||
}
|
||||
await up();
|
||||
};
|
||||
const startSlow = await stats(page);
|
||||
await fling(90);
|
||||
await rest(page);
|
||||
const slow = await stats(page);
|
||||
const slowDistance = slow.selectedCell.row - startSlow.selectedCell.row;
|
||||
await fling(8);
|
||||
const released = await stats(page);
|
||||
assert.equal(released.archiveMomentum?.phase, "coasting");
|
||||
assert.ok(released.archiveMomentum.velocity.row > 2);
|
||||
await page.waitForTimeout(450);
|
||||
const moving = await stats(page);
|
||||
assert.ok(
|
||||
moving.selectedCell.row > released.selectedCell.row,
|
||||
"Files change as the released array passes them",
|
||||
);
|
||||
assert.ok(
|
||||
moving.rail < released.rail - 0.3,
|
||||
"Released array keeps travelling",
|
||||
);
|
||||
assert.ok(
|
||||
moving.archiveMomentum.velocity.row <
|
||||
released.archiveMomentum.velocity.row,
|
||||
"Speed decays continuously",
|
||||
);
|
||||
await rest(page);
|
||||
const fast = await stats(page);
|
||||
const fastDistance = fast.selectedCell.row - slow.selectedCell.row;
|
||||
console.log({
|
||||
mobile,
|
||||
slowDistance,
|
||||
fastDistance,
|
||||
releaseVelocity: released.archiveMomentum.velocity.row,
|
||||
});
|
||||
assert.ok(
|
||||
fastDistance > slowDistance,
|
||||
"Fast travel goes farther than identical slow travel",
|
||||
);
|
||||
assert.ok(
|
||||
fastDistance > 3,
|
||||
"Fast scrolling is no longer capped at three files",
|
||||
);
|
||||
|
||||
// Catch the array without moving the pointer, then take over on another axis.
|
||||
await fling(8);
|
||||
await page.waitForTimeout(120);
|
||||
await down();
|
||||
const caught = await stats(page);
|
||||
assert.equal(caught.archiveMomentum, null);
|
||||
assert.equal(caught.holdingArchive, true);
|
||||
await page.waitForTimeout(220);
|
||||
const held = await stats(page);
|
||||
assert.equal(
|
||||
held.rail,
|
||||
caught.rail,
|
||||
"Pressing catches the current rail before direction lock",
|
||||
);
|
||||
assert.deepEqual(held.selectedCell, caught.selectedCell);
|
||||
const capturedVector = caught.dragProjection.lane;
|
||||
await move(x + capturedVector.x * 0.8, y + capturedVector.y * 0.8);
|
||||
await page.waitForTimeout(160);
|
||||
await up();
|
||||
await rest(page);
|
||||
assert.equal(
|
||||
(await stats(page)).selectedCell.lane,
|
||||
caught.selectedCell.lane + 1,
|
||||
);
|
||||
|
||||
await fling(8, "lane");
|
||||
const lateral = await stats(page);
|
||||
assert.ok(
|
||||
lateral.archiveMomentum?.velocity.lane > 0,
|
||||
JSON.stringify(lateral.archiveMomentum),
|
||||
);
|
||||
await page.waitForTimeout(250);
|
||||
assert.ok((await stats(page)).columnCamera > lateral.columnCamera + 0.5);
|
||||
await rest(page);
|
||||
|
||||
await fling(8);
|
||||
await page.keyboard.press("ArrowDown");
|
||||
const keyboard = await stats(page);
|
||||
assert.equal(
|
||||
keyboard.archiveMomentum,
|
||||
null,
|
||||
"Keyboard selection stops the previous coast",
|
||||
);
|
||||
await page.waitForTimeout(500);
|
||||
assert.deepEqual((await stats(page)).selectedCell, keyboard.selectedCell);
|
||||
|
||||
await page.locator('[data-action="settings"]').click();
|
||||
await page.locator('[data-pref="reduced"]').check({ force: true });
|
||||
await page.locator('[data-action="close-modal"]').click();
|
||||
await page.waitForFunction(
|
||||
() => !document.querySelector(".modal-backdrop"),
|
||||
);
|
||||
await fling(8);
|
||||
assert.equal(
|
||||
(await stats(page)).archiveMomentum,
|
||||
null,
|
||||
"Reduced motion skips continued scrolling",
|
||||
);
|
||||
assert.deepEqual(errors, []);
|
||||
report.push({
|
||||
mobile,
|
||||
slowDistance,
|
||||
fastDistance,
|
||||
releaseVelocity: released.archiveMomentum.velocity.row,
|
||||
releaseRow: released.selectedCell.row,
|
||||
movingRow: moving.selectedCell.row,
|
||||
checks: "passed",
|
||||
});
|
||||
console.log(report.at(-1));
|
||||
await context.close();
|
||||
}
|
||||
} finally {
|
||||
await mkdir(".tools/array-input", { recursive: true });
|
||||
await writeFile(
|
||||
resolve(".tools/array-input/momentum.json"),
|
||||
JSON.stringify(report, null, 2),
|
||||
);
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import * as THREE from 'three';
|
||||
import { ArchiveVisibility } from '../src/archive-visibility.ts';
|
||||
const visibility=new ArchiveVisibility();
|
||||
const summaries=[];
|
||||
for(const [width,height] of [[1920,1080],[2560,1080],[3840,1080],[1400,1050],[1080,1920]])for(const detail of [false,true]) {
|
||||
const camera=new THREE.PerspectiveCamera(3.0,width/height,5,300);
|
||||
const direction=new THREE.Vector3(...(detail?[-.277,.238,.931]:[-.81,.326,.487])).normalize();
|
||||
const distance=detail?72:140;
|
||||
const span=detail?5.9:width<height?12:7.33;
|
||||
camera.fov=THREE.MathUtils.radToDeg(2*Math.atan(span/(2*distance)));
|
||||
camera.position.copy(direction.multiplyScalar(distance));camera.lookAt(0,0,0);camera.updateProjectionMatrix();camera.updateMatrixWorld();
|
||||
const candidates=visibility.update(camera,distance+25,0,0,false);
|
||||
const selected=candidates.filter(c=>visibility.intersects((c.lane-2)*5.2,-4.6,(c.row-15.5)*.62));
|
||||
const keys=new Set(selected.map(c=>`${c.lane}:${c.row}`));assert.equal(keys.size,selected.length);
|
||||
// Independently scan a much larger reference lattice. Every intersecting card
|
||||
// must have been generated by the projected footprint, at both extreme lifts.
|
||||
const candidateKeys=new Set(candidates.map(c=>`${c.lane}:${c.row}`));
|
||||
const viewProjection=new THREE.Matrix4().multiplyMatrices(camera.projectionMatrix,camera.matrixWorldInverse);
|
||||
for(const y of [-6,1.6])for(let lane=-30;lane<=30;lane++)for(let row=-180;row<=180;row++) {
|
||||
for(const dx of [-2.5,0,2.5])for(const dy of [0,1.85,3.7])for(const dz of [-.3,.3]) {
|
||||
const point=new THREE.Vector3((lane-2)*5.2+dx,y+dy,(row-15.5)*.62+dz);
|
||||
const depth=-point.clone().applyMatrix4(camera.matrixWorldInverse).z;
|
||||
point.applyMatrix4(viewProjection);
|
||||
if(Math.abs(point.x)<=1.18&&Math.abs(point.y)<=1.18&&depth>=5&&depth<=distance+33)assert.ok(candidateKeys.has(`${lane}:${row}`),`Missing ${width}x${height} ${lane}:${row} y=${y}`);
|
||||
}
|
||||
}
|
||||
const extra=visibility.update(camera,distance+25,0,0,true).filter(c=>visibility.intersects((c.lane-2)*5.2,-4.6,(c.row-15.5)*.62));
|
||||
assert.ok(extra.length>=selected.length);
|
||||
const shifted=visibility.update(camera,distance+25,52,-6.2,false).filter(c=>visibility.intersects((c.lane-2)*5.2-52,-4.6,(c.row-15.5)*.62-6.2));
|
||||
assert.equal(shifted.length,selected.length,'Whole-cell track movement preserves view coverage');
|
||||
summaries.push({width,height,detail,drawn:selected.length,candidates:candidates.length,extra:extra.length});
|
||||
}
|
||||
assert.ok(summaries[4].drawn>summaries[0].drawn,'Wider view increases archive count');
|
||||
console.table(summaries);console.log('Projected coverage, extreme heights, unique picking cells, overscan and track translation passed.');
|
||||
@@ -0,0 +1,143 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import * as THREE from "three";
|
||||
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
|
||||
import {
|
||||
records,
|
||||
archiveColumns,
|
||||
columnFiles,
|
||||
fileLocation,
|
||||
fileAtSlot,
|
||||
} from "../src/data.ts";
|
||||
import {
|
||||
cinematicField,
|
||||
columnStrength,
|
||||
INSPECTION_LIFT,
|
||||
returnStep,
|
||||
damp,
|
||||
} from "../src/motion.ts";
|
||||
|
||||
// 条数不再有上限(校验器只要求每列 ≥1、总数 ≥5),因此这里不写死 40 / 8,
|
||||
// 而是按数据推导并检查真正的契约:每列非空、列内槽位互不冲突。
|
||||
assert.ok(records.length >= 5, "Archive must carry at least five documents");
|
||||
const slots = new Set();
|
||||
const perColumn = [];
|
||||
let maxPerColumn = 0;
|
||||
for (let lane = 0; lane < archiveColumns.length; lane++) {
|
||||
const files = columnFiles(lane);
|
||||
assert.ok(files.length >= 1, "Every column has at least one readable file");
|
||||
perColumn.push(files.length);
|
||||
maxPerColumn = Math.max(maxPerColumn, files.length);
|
||||
const laneSlots = new Set();
|
||||
for (const index of files) {
|
||||
const location = fileLocation(index);
|
||||
assert.equal(location.lane, lane);
|
||||
assert.equal(fileAtSlot(location.slot), index);
|
||||
assert.ok(location.row >= 0 && location.row < 32);
|
||||
// slot = lane * 32 + (row % 32):同一列里超过 32 条才会开始复用槽位,
|
||||
// 届时 fileAtSlot(截断语义)本就不是用来寻址这些条目的,所以只断言列内不冲突。
|
||||
assert.ok(
|
||||
!laneSlots.has(location.slot) || files.length > 32,
|
||||
`Slots within one column must stay distinct (lane ${lane})`,
|
||||
);
|
||||
laneSlots.add(location.slot);
|
||||
slots.add(location.slot);
|
||||
const record = records[index];
|
||||
// 归档内容取代原 demo 之后,字数与条数不再固定:
|
||||
// 契约以 scripts/archive-content.mjs 的校验器为准——摘要非空、研究记录至少一条。
|
||||
// 这里保留一个极低的下限,只防止空壳内容混入。
|
||||
assert.ok(
|
||||
record.abstract.trim().length > 20,
|
||||
`Record abstract must carry real text (got ${record.abstract.trim().length} chars)`,
|
||||
);
|
||||
assert.ok(
|
||||
record.findings.length >= 1 &&
|
||||
record.findings.every((line) => String(line).trim().length > 0),
|
||||
"Every record carries at least one non-empty research note",
|
||||
);
|
||||
// 本地归档条目的参考链接是 file:(浏览器不允许网页跳转,仅供溯源);
|
||||
// 公开设定类条目仍用 https:。
|
||||
assert.ok(
|
||||
["https:", "file:"].includes(new URL(record.source).protocol),
|
||||
`Record source must be https: or file: (got ${new URL(record.source).protocol})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// 每列不超过 32 条时,所有条目的槽位应当恰好铺满、互不重叠。
|
||||
if (maxPerColumn <= 32) {
|
||||
assert.equal(slots.size, records.length, "No two documents occupy the same slot");
|
||||
}
|
||||
const crests = Array.from({ length: 5 }, (_, lane) =>
|
||||
Math.max(
|
||||
...Array.from({ length: 32 }, (_, row) => cinematicField(row, lane, 25.4)),
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
Math.max(...crests) - Math.min(...crests) < 1e-9,
|
||||
"Frame 760 crests share the same height",
|
||||
);
|
||||
assert.ok(columnStrength(0, 2) >= 0.25, "Other columns retain a visible wave");
|
||||
assert.ok(columnStrength(2, 2) > columnStrength(1, 2));
|
||||
let maxDelta = 0;
|
||||
for (let f = 750; f < 786; f++) {
|
||||
for (let row = 0; row < 32; row++)
|
||||
for (let lane = 0; lane < 5; lane++) {
|
||||
maxDelta = Math.max(
|
||||
maxDelta,
|
||||
Math.abs(
|
||||
cinematicField(row, lane, (f + 1) / 25 - 5) -
|
||||
cinematicField(row, lane, f / 25 - 5),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
assert.ok(
|
||||
maxDelta < 0.65,
|
||||
"The equal-crest to selected-column handoff is continuous",
|
||||
);
|
||||
|
||||
// Validate against the delivered Blender model, not a duplicate nominal box.
|
||||
const bytes = await readFile(
|
||||
new URL("../public/assets/archive-cassette.glb", import.meta.url),
|
||||
);
|
||||
const gltf = await new GLTFLoader().parseAsync(
|
||||
bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength),
|
||||
"",
|
||||
);
|
||||
const box = new THREE.Box3().setFromObject(gltf.scene);
|
||||
const height = box.max.y - box.min.y;
|
||||
assert.ok(
|
||||
INSPECTION_LIFT - height > 0.25,
|
||||
"Inspection clears the adjacent card while staying near the array",
|
||||
);
|
||||
assert.ok(INSPECTION_LIFT <= 4.1, "Inspection lift remains modest");
|
||||
let angle = 0.8,
|
||||
elapsed = 0;
|
||||
while (angle !== 0 && elapsed < 2) {
|
||||
angle = returnStep(angle, 1 / 60);
|
||||
elapsed += 1 / 60;
|
||||
}
|
||||
assert.equal(angle, 0, "Alignment finishes exactly before insertion");
|
||||
assert.ok(elapsed > 0.5 && elapsed < 1.2);
|
||||
const coarse = { value: 2, velocity: 0 },
|
||||
fine = { ...coarse };
|
||||
for (let i = 0; i < 30; i++) damp(coarse, 3, 4, 1 / 30);
|
||||
for (let i = 0; i < 120; i++) damp(fine, 3, 4, 1 / 120);
|
||||
assert.ok(Math.abs(coarse.value - fine.value) < 1e-9);
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
documents: records.length,
|
||||
perColumn,
|
||||
maxPerColumn,
|
||||
crestsAt760: crests,
|
||||
maxFrameDelta: maxDelta,
|
||||
modelHeight: height,
|
||||
inspectionLift: INSPECTION_LIFT,
|
||||
alignmentSeconds: elapsed,
|
||||
checks: "passed",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,294 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
const { chromium } = await import(
|
||||
process.env.PLAYWRIGHT_MODULE
|
||||
? pathToFileURL(resolve(process.env.PLAYWRIGHT_MODULE)).href
|
||||
: "playwright"
|
||||
);
|
||||
const browser = await chromium.launch({
|
||||
channel: "chrome",
|
||||
headless: true,
|
||||
args: ["--use-angle=d3d11", "--enable-gpu", "--ignore-gpu-blocklist"],
|
||||
});
|
||||
const output = resolve(".tools/array-input");
|
||||
await mkdir(output, { recursive: true });
|
||||
const report = [];
|
||||
const stats = (page) => page.evaluate(() => window.rhine.stats());
|
||||
const settle = (page) => page.waitForTimeout(2200);
|
||||
try {
|
||||
for (const mobile of [false, true].filter(
|
||||
(mobile) =>
|
||||
!process.env.REVIEW_CASES ||
|
||||
process.env.REVIEW_CASES === (mobile ? "mobile" : "desktop"),
|
||||
)) {
|
||||
const width = mobile ? 390 : 1920,
|
||||
height = mobile ? 844 : 1080;
|
||||
const context = await browser.newContext({
|
||||
viewport: { width, height },
|
||||
hasTouch: mobile,
|
||||
isMobile: mobile,
|
||||
});
|
||||
const page = await context.newPage(),
|
||||
errors = [];
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
await page.goto(
|
||||
`${process.env.REVIEW_URL || "http://127.0.0.1:5204"}/?scene=archive`,
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => window.rhine?.stats().ready && !document.querySelector("#loading"),
|
||||
null,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => window.rhine.stats().extraction >= 0.399,
|
||||
null,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await settle(page);
|
||||
const start = await stats(page);
|
||||
const x = width * 0.55,
|
||||
y = height * 0.3;
|
||||
const laneStep = Math.max(100, Math.min(280, width * 0.24));
|
||||
const rowStep = Math.max(72, Math.min(150, height * 0.14));
|
||||
const cdp = mobile ? await context.newCDPSession(page) : null;
|
||||
let origin, projection, inspecting, pointer;
|
||||
const downRaw = async (x, y) =>
|
||||
mobile
|
||||
? cdp.send("Input.dispatchTouchEvent", {
|
||||
type: "touchStart",
|
||||
touchPoints: [{ x, y, id: 1 }],
|
||||
})
|
||||
: (await page.mouse.move(x, y), page.mouse.down());
|
||||
const moveRaw = async (x, y) =>
|
||||
mobile
|
||||
? cdp.send("Input.dispatchTouchEvent", {
|
||||
type: "touchMove",
|
||||
touchPoints: [{ x, y, id: 1 }],
|
||||
})
|
||||
: page.mouse.move(x, y);
|
||||
const down = async (px, py) => {
|
||||
await downRaw(px, py);
|
||||
const state = await stats(page);
|
||||
origin = { x: px, y: py };
|
||||
pointer = origin;
|
||||
projection = state.dragProjection;
|
||||
inspecting = state.canInspect;
|
||||
};
|
||||
// Existing behavioral cases use logical column/file distances; project them.
|
||||
const move = async (px, py) => {
|
||||
if (inspecting) return moveRaw(px, py);
|
||||
const lane = (origin.x - px) / laneStep,
|
||||
row = (origin.y - py) / rowStep;
|
||||
pointer = {
|
||||
x: origin.x + lane * projection.lane.x + row * projection.row.x,
|
||||
y: origin.y + lane * projection.lane.y + row * projection.row.y,
|
||||
};
|
||||
return moveRaw(pointer.x, pointer.y);
|
||||
};
|
||||
const up = async () =>
|
||||
mobile
|
||||
? cdp.send("Input.dispatchTouchEvent", {
|
||||
type: "touchEnd",
|
||||
touchPoints: [],
|
||||
})
|
||||
: page.mouse.up();
|
||||
await down(x, y);
|
||||
await move(x - laneStep * 0.3, y + 2);
|
||||
await page.waitForTimeout(150);
|
||||
const partial = await stats(page);
|
||||
assert.ok(partial.dragTrack);
|
||||
assert.equal(partial.selectedCell.lane, start.selectedCell.lane);
|
||||
assert.ok(
|
||||
partial.columnCamera > start.columnCamera + 1,
|
||||
"Array follows before selecting the next cell",
|
||||
);
|
||||
await move(x - laneStep * 0.8, y + 3);
|
||||
await page.waitForTimeout(150);
|
||||
assert.equal(
|
||||
(await stats(page)).selectedCell.lane,
|
||||
start.selectedCell.lane + 1,
|
||||
"Selection changes before release",
|
||||
);
|
||||
await page.screenshot({
|
||||
path: resolve(output, `${mobile ? "touch" : "mouse"}-drag.png`),
|
||||
});
|
||||
await up();
|
||||
await settle(page);
|
||||
const lane = await stats(page);
|
||||
assert.equal(lane.selectedCell.lane, start.selectedCell.lane + 1);
|
||||
assert.equal(lane.dragTrack, null);
|
||||
assert.ok(
|
||||
Math.abs(lane.columnCamera - (lane.selectedCell.lane - 2) * 5.2) < 0.03,
|
||||
);
|
||||
await down(x, y);
|
||||
await move(x + 2, y - rowStep * 0.8);
|
||||
await page.waitForTimeout(150);
|
||||
assert.equal(
|
||||
(await stats(page)).selectedCell.row,
|
||||
lane.selectedCell.row + 1,
|
||||
);
|
||||
await move(x + 4, y + rowStep * 0.8);
|
||||
await page.waitForTimeout(150);
|
||||
const reverse = await stats(page);
|
||||
assert.equal(reverse.selectedCell.lane, lane.selectedCell.lane);
|
||||
assert.equal(
|
||||
reverse.selectedCell.row,
|
||||
lane.selectedCell.row - 1,
|
||||
"Reversing mid-drag switches to previous file",
|
||||
);
|
||||
await up();
|
||||
await settle(page);
|
||||
if (!mobile) {
|
||||
// Find a visible cover using actual scene hit-testing, then hold the pointer still.
|
||||
let hit = false;
|
||||
for (const point of [
|
||||
[960, 450],
|
||||
[800, 440],
|
||||
[1100, 400],
|
||||
[600, 500],
|
||||
[1300, 400],
|
||||
]) {
|
||||
await page.mouse.move(...point);
|
||||
await page.waitForTimeout(50);
|
||||
if ((await stats(page)).hoverCell) {
|
||||
hit = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert.ok(hit, "A visible card can be hovered");
|
||||
await page.waitForTimeout(450);
|
||||
const hovered = await stats(page);
|
||||
assert.ok(
|
||||
Object.values(hovered.hoverLifts).some((v) => v > 0.27),
|
||||
"Hover raises the card",
|
||||
);
|
||||
assert.equal(
|
||||
hovered.extraction,
|
||||
0.4,
|
||||
"Hover does not change extraction or camera progress",
|
||||
);
|
||||
await page.screenshot({ path: resolve(output, "mouse-hover.png") });
|
||||
await page.mouse.down();
|
||||
await page.mouse.up();
|
||||
assert.deepEqual(
|
||||
(await stats(page)).selectedCell,
|
||||
hovered.hoverCell,
|
||||
"Click still selects the hovered physical cell",
|
||||
);
|
||||
await page.mouse.move(5, 5);
|
||||
await page.waitForTimeout(700);
|
||||
assert.equal((await stats(page)).hoverCell, null);
|
||||
assert.deepEqual((await stats(page)).hoverLifts, {});
|
||||
await page.mouse.move(x, y);
|
||||
const beforeWheel = await stats(page);
|
||||
await page.mouse.wheel(0, 100);
|
||||
await page.waitForTimeout(100);
|
||||
assert.equal(
|
||||
(await stats(page)).selectedCell.row,
|
||||
beforeWheel.selectedCell.row + 1,
|
||||
);
|
||||
await page.mouse.wheel(0, -100);
|
||||
await settle(page);
|
||||
assert.equal(
|
||||
(await stats(page)).selectedCell.row,
|
||||
beforeWheel.selectedCell.row,
|
||||
);
|
||||
await page.locator('[data-action="settings"]').click();
|
||||
await page.waitForTimeout(350);
|
||||
const modal = await stats(page);
|
||||
await page.mouse.move(x, y);
|
||||
await page.mouse.wheel(0, 400);
|
||||
await page.waitForTimeout(200);
|
||||
assert.deepEqual(
|
||||
(await stats(page)).selectedCell,
|
||||
modal.selectedCell,
|
||||
"Modal blocks array wheel navigation",
|
||||
);
|
||||
await page.locator('[data-action="close-modal"]').click();
|
||||
await page.waitForFunction(
|
||||
() => !document.querySelector(".modal-backdrop"),
|
||||
);
|
||||
}
|
||||
// Run across a complete row cycle, checking physical direction at the seam.
|
||||
const loop = await stats(page);
|
||||
for (let i = 0; i < 8; i++)
|
||||
await page.locator('[data-action="next"]').click();
|
||||
assert.equal((await stats(page)).selected, loop.selected);
|
||||
assert.equal(
|
||||
(await stats(page)).selectedCell.row,
|
||||
loop.selectedCell.row + 8,
|
||||
);
|
||||
await settle(page);
|
||||
const interrupted = await stats(page);
|
||||
await down(x, y);
|
||||
await move(x - laneStep * 0.3, y);
|
||||
await page.waitForTimeout(100);
|
||||
if (mobile) {
|
||||
await cdp.send("Input.dispatchTouchEvent", {
|
||||
type: "touchStart",
|
||||
touchPoints: [
|
||||
{ ...pointer, id: 1 },
|
||||
{ x: x + 30, y: y + 30, id: 2 },
|
||||
],
|
||||
});
|
||||
} else {
|
||||
await page.evaluate(() =>
|
||||
document.querySelector("#three-scene canvas").releasePointerCapture(1),
|
||||
);
|
||||
}
|
||||
await up();
|
||||
await settle(page);
|
||||
assert.equal(
|
||||
(await stats(page)).dragTrack,
|
||||
null,
|
||||
"Interrupted drag clears capture state",
|
||||
);
|
||||
assert.deepEqual(
|
||||
(await stats(page)).selectedCell,
|
||||
interrupted.selectedCell,
|
||||
"Interrupted partial drag does not select",
|
||||
);
|
||||
// A subsequent gesture must work after cancellation / multi-touch.
|
||||
await down(x, y);
|
||||
await move(x, y - rowStep * 0.8);
|
||||
await page.waitForTimeout(150);
|
||||
await up();
|
||||
await settle(page);
|
||||
assert.equal(
|
||||
(await stats(page)).selectedCell.row,
|
||||
interrupted.selectedCell.row + 1,
|
||||
);
|
||||
await page.locator(".read-file").click();
|
||||
await page.waitForFunction(() => window.rhine.stats().canInspect, null, {
|
||||
timeout: 30000,
|
||||
});
|
||||
const detail = await stats(page);
|
||||
await down(x, y);
|
||||
await move(x + 60, y + 5);
|
||||
await up();
|
||||
await page.waitForTimeout(500);
|
||||
assert.deepEqual(
|
||||
(await stats(page)).selectedCell,
|
||||
detail.selectedCell,
|
||||
"Detail drag only rotates",
|
||||
);
|
||||
assert.ok((await stats(page)).rotation > 0.05);
|
||||
assert.deepEqual(errors, []);
|
||||
report.push({
|
||||
mobile,
|
||||
start: start.selectedCell,
|
||||
after: reverse.selectedCell,
|
||||
checks: "passed",
|
||||
});
|
||||
console.log(`${mobile ? "Touch" : "Mouse"} input passed`);
|
||||
await context.close();
|
||||
}
|
||||
} finally {
|
||||
await writeFile(
|
||||
resolve(output, "report.json"),
|
||||
JSON.stringify(report, null, 2),
|
||||
);
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import * as THREE from "three";
|
||||
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
|
||||
import { damp } from "../src/motion.ts";
|
||||
async function load(name) {
|
||||
const b = await readFile(
|
||||
new URL("../public/assets/" + name, import.meta.url),
|
||||
);
|
||||
return (
|
||||
await new GLTFLoader().parseAsync(
|
||||
b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength),
|
||||
"",
|
||||
)
|
||||
).scene;
|
||||
}
|
||||
const [original, assembly] = await Promise.all([
|
||||
load("archive-cassette.glb"),
|
||||
load("archive-assembly.glb"),
|
||||
]);
|
||||
const parts = new Map();
|
||||
const vertices = (scene, collect = false) => {
|
||||
const points = new Map();
|
||||
scene.updateMatrixWorld(true);
|
||||
scene.traverse((mesh) => {
|
||||
if (!mesh.isMesh) return;
|
||||
const surface = mesh.material.name.replace(/\.\d+$/, "");
|
||||
if (surface === "Carbon_Ink") return;
|
||||
if (collect) {
|
||||
assert.ok(
|
||||
mesh.userData.assemblyPart,
|
||||
"Every mesh belongs to a physical assembly",
|
||||
);
|
||||
parts.set(
|
||||
mesh.userData.assemblyPart,
|
||||
(parts.get(mesh.userData.assemblyPart) || 0) + 1,
|
||||
);
|
||||
}
|
||||
const position = mesh.geometry.attributes.position;
|
||||
for (let i = 0; i < position.count; i++) {
|
||||
const point = new THREE.Vector3()
|
||||
.fromBufferAttribute(position, i)
|
||||
.applyMatrix4(mesh.matrixWorld);
|
||||
points.set(surface + ":" + point.toArray().join(","), { surface, point });
|
||||
}
|
||||
});
|
||||
return points;
|
||||
};
|
||||
const a = vertices(original),
|
||||
b = vertices(assembly, true);
|
||||
assert.deepEqual([...parts.keys()].sort(), [
|
||||
"carrier",
|
||||
"cover",
|
||||
"fasteners",
|
||||
"optical-core",
|
||||
"optical-lenses",
|
||||
"substrate",
|
||||
]);
|
||||
// Compare actual distances: rounding to a fixed grid can split equivalent
|
||||
// float32 coordinates on either side of a rounding boundary after Blender joins.
|
||||
function maxVertexError(from, to) {
|
||||
const surfaces = new Map();
|
||||
for (const { surface, point } of to.values()) {
|
||||
if (!surfaces.has(surface)) surfaces.set(surface, []);
|
||||
surfaces.get(surface).push(point);
|
||||
}
|
||||
let max = 0;
|
||||
for (const { surface, point } of from.values()) {
|
||||
const candidates = surfaces.get(surface) || [];
|
||||
let nearest = Infinity;
|
||||
for (const candidate of candidates)
|
||||
nearest = Math.min(nearest, point.distanceToSquared(candidate));
|
||||
max = Math.max(max, Math.sqrt(nearest));
|
||||
}
|
||||
return max;
|
||||
}
|
||||
const vertexError = Math.max(maxVertexError(a, b), maxVertexError(b, a));
|
||||
assert.ok(
|
||||
vertexError < 1e-5,
|
||||
`Regrouping must retain assembled geometry within float32 tolerance: ${vertexError}`,
|
||||
);
|
||||
const height = new THREE.Box3()
|
||||
.setFromObject(assembly)
|
||||
.getSize(new THREE.Vector3()).y;
|
||||
assert.ok(Math.abs(height - 3.7) < 1e-5);
|
||||
// Interrupted motion retains position and velocity, then converges exactly enough
|
||||
// for the viewer to snap to the original assembly pose.
|
||||
const spread = { value: 0, velocity: 0 };
|
||||
for (let i = 0; i < 25; i++) damp(spread, 1, 5.5, 1 / 60);
|
||||
const interrupted = spread.value;
|
||||
damp(spread, 0, 5.5, 1 / 60);
|
||||
assert.ok(
|
||||
Math.abs(spread.value - interrupted) < 0.05,
|
||||
"Reassembly must not jump on reversal",
|
||||
);
|
||||
for (let i = 0; i < 180; i++) damp(spread, 0, 5.5, 1 / 60);
|
||||
assert.ok(Math.abs(spread.value) < 0.0001 && Math.abs(spread.velocity) < 0.001);
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
parts: Object.fromEntries(parts),
|
||||
uniqueSurfaceVertices: a.size,
|
||||
vertexError,
|
||||
modelHeight: height,
|
||||
reassembly: spread.value,
|
||||
checks: "passed",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,33 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {createRequire} from 'node:module';
|
||||
import {createServer} from 'node:http';
|
||||
import {readFile,mkdir,writeFile} from 'node:fs/promises';
|
||||
import {resolve,extname} from 'node:path';
|
||||
const {chromium}=createRequire(import.meta.url)(process.env.PLAYWRIGHT_MODULE||'playwright');
|
||||
const server=createServer(async(req,res)=>{try{let path=new URL(req.url,'http://localhost').pathname;if(path==='/')path='/index.html';let data=await readFile(resolve('release/wallpaper'+path));if(path.endsWith('.html'))data=Buffer.from(data.toString().replace('</head>',`<script>wallpaperPropertyListener.applyUserProperties({load3donstartup:{value:false},desktopmode:{value:'workbench'},sound:{value:false},music:{value:false},hudparallax:{value:true},huddepth:{value:60},hudtracking:{value:true}})</script></head>`));res.setHeader('Content-Type',({'.js':'text/javascript','.css':'text/css','.html':'text/html','.woff2':'font/woff2'})[extname(path)]||'application/octet-stream');res.end(data)}catch{res.statusCode=404;res.end()}});
|
||||
await new Promise(r=>server.listen(5189,'127.0.0.1',r));
|
||||
const browser=await chromium.launch({channel:'msedge',headless:true});
|
||||
try {
|
||||
const page=await browser.newPage({viewport:{width:1600,height:900}}),errors=[],results=[];
|
||||
page.on('pageerror',e=>errors.push(e.message));
|
||||
const apply=values=>page.evaluate(values=>wallpaperPropertyListener.applyUserProperties(Object.fromEntries(Object.entries(values).map(([k,value])=>[k,{value}]))),values);
|
||||
const shot=()=>page.evaluate(()=>({mode:rhine.stats().mode,hud:document.querySelector('#stage').dataset.hudDepth,tracking:document.querySelector('#stage').dataset.hudTracking,panels:[...document.querySelectorAll('.boot .hud-surface')].map(n=>({name:n.className,base:n.style.transform,projection:n.style.getPropertyValue('--hud-projection'),transform:getComputedStyle(n).transform}))}));
|
||||
await page.goto('http://127.0.0.1:5189/');await page.waitForFunction(()=>window.rhine?.stats().ready);await page.waitForTimeout(600);
|
||||
await page.evaluate(()=>rhine.seek(7));await page.waitForTimeout(150);
|
||||
const glyphs=await page.evaluate(async()=>{const text=document.querySelector('.boot-logo text'),first=text.firstChild;let mutations=0;const observer=new MutationObserver(records=>mutations+=records.length);observer.observe(text,{childList:true,characterData:true,subtree:true});await new Promise(r=>setTimeout(r,650));observer.disconnect();return {mutations,sameNode:first===text.firstChild,value:text.textContent}});
|
||||
assert.equal(glyphs.mutations,0,'Completed logo lettering must not be rebuilt each frame');assert.equal(glyphs.sameNode,true);assert.equal(glyphs.value,'RHINE·LAB');
|
||||
await mkdir('verification/boot-hud',{recursive:true});
|
||||
for(const [name,time] of [['logo',6.5],['scan',15.3],['welcome',19.6]]) {
|
||||
await page.evaluate(time=>rhine.seek(time),time);await page.mouse.move(1450,120);await page.waitForTimeout(300);
|
||||
const state=await shot();assert.equal(state.mode,'boot');assert.equal(state.hud,'true');assert.equal(state.tracking,'true');assert.equal(state.panels.length,5);
|
||||
assert.ok(state.panels.every(p=>p.projection.startsWith('matrix3d(')&&!p.projection.includes('NaN')));
|
||||
results.push({name,...state});await page.screenshot({path:`verification/boot-hud/${name}.png`});
|
||||
}
|
||||
await apply({hudtracking:false});await page.waitForTimeout(1800);const fixed=await shot();await page.mouse.move(80,800);await page.waitForTimeout(400);assert.equal((await shot()).tracking,'false');assert.equal(fixed.hud,'true');
|
||||
await apply({hudparallax:false});await page.waitForTimeout(2000);assert.equal((await shot()).hud,'false');
|
||||
await apply({hudparallax:true,reduced:true});await page.evaluate(()=>rhine.seek(12));await page.waitForTimeout(100);assert.equal((await shot()).hud,'true');assert.equal((await shot()).tracking,'false');
|
||||
await page.evaluate(()=>rhine.archive());await page.waitForTimeout(200);assert.equal((await shot()).hud,'true');assert.equal((await shot()).mode,'archive');
|
||||
await apply({reduced:false});await page.evaluate(()=>rhine.seek(12));await page.setViewportSize({width:2560,height:1080});await page.waitForTimeout(400);assert.equal((await shot()).hud,'true');await page.screenshot({path:'verification/boot-hud/ultrawide.png'});
|
||||
assert.deepEqual(errors,[]);await writeFile('verification/boot-hud/results.json',JSON.stringify({glyphs,results,errors,off:true,static:true,reduced:true,modeTransition:true,resize:true},null,2));
|
||||
console.log('Boot HUD: all five panels, authored transforms, tracking/static/off, reduced motion, mode transition and ultrawide passed.');
|
||||
} finally {await browser.close();server.close();}
|
||||
@@ -0,0 +1,34 @@
|
||||
import {createRequire} from 'node:module';
|
||||
import assert from 'node:assert/strict';
|
||||
import {mkdir,writeFile} from 'node:fs/promises';
|
||||
const require=createRequire(import.meta.url);
|
||||
const {chromium}=require(process.env.PLAYWRIGHT_MODULE || 'playwright');
|
||||
const browser=await chromium.launch({channel:'msedge',headless:true});
|
||||
const page=await browser.newPage({viewport:{width:1920,height:1080}});
|
||||
const errors=[];page.on('pageerror',e=>errors.push(String(e)));
|
||||
await page.goto('http://127.0.0.1:5176/?scene=archive');
|
||||
await page.waitForFunction(()=>window.wallpaperPropertyListener&&document.querySelector('.wb-clock')?.textContent);
|
||||
async function apply(values){await page.evaluate(values=>window.wallpaperPropertyListener.applyUserProperties(Object.fromEntries(Object.entries(values).map(([k,value])=>[k,{value}]))),values);await page.waitForTimeout(650)}
|
||||
await apply({desktopmode:'workbench',boot:false,music:false,sound:false,reduced:true,hudtracking:false,uifrost:true,screenfinish:true,screengrain:20,screenfringe:20,screenvignette:20,task1:'记录本次实验结果',task2:'整理观测资料',task3:'完成今日工作'});
|
||||
await page.waitForTimeout(1500);
|
||||
const selectors=['.wb-overview','.wb-module','.brand','.wb-nav button','.relay-entry','.system-footer > span','.system-footer > button'];
|
||||
async function measure(){return page.evaluate(selectors=>selectors.map(s=>{const n=document.querySelector(s),r=n.getBoundingClientRect(),c=getComputedStyle(n);return {s,x:r.x,y:r.y,w:r.width,h:r.height,overflowX:c.overflowX,overflowY:c.overflowY}}),selectors)}
|
||||
const result=[];
|
||||
for(const [width,height] of [[1920,1080],[2560,1440],[1600,900]]){
|
||||
await page.setViewportSize({width,height});
|
||||
for(const depth of [0,20,80]){
|
||||
await apply({hudparallax:depth>0,huddepth:depth,uimarginbottom:0});await page.waitForTimeout(5000);const baseline=await measure();
|
||||
for(const bottom of [60,192,300,-80,0]){
|
||||
await apply({uimarginbottom:bottom});const current=await measure();
|
||||
current.forEach((a,i)=>{const b=baseline[i],shift=i>=3?bottom:0;assert.ok(Math.abs(a.y-b.y+shift)<1.1,JSON.stringify({width,depth,bottom,a,b,shift}));for(const k of ['x','w','h'])assert.ok(Math.abs(a[k]-b[k])<1.1,`${a.s} ${k} changed`);if(i<2){assert.equal(a.overflowX,'visible');assert.equal(a.overflowY,'visible')}});
|
||||
result.push({width,height,depth,bottom,upperShift:current[3].y-baseline[3].y,lowerShift:current[5].y-baseline[5].y,middleShift:current[0].y-baseline[0].y});
|
||||
}
|
||||
}
|
||||
}
|
||||
await page.setViewportSize({width:1920,height:1080});await apply({hudparallax:true,huddepth:20,uimarginbottom:0});
|
||||
await mkdir('verification/ui-insets',{recursive:true});await page.screenshot({path:'verification/ui-insets/bottom-0.png'});
|
||||
await apply({uimarginbottom:192});await page.screenshot({path:'verification/ui-insets/bottom-192.png'});
|
||||
assert.deepEqual(errors,[]);await writeFile('verification/ui-insets/results.json',JSON.stringify(result,null,2));
|
||||
console.log(`Passed ${result.length} viewport/depth/margin combinations; fixed middle geometry, no overflow, identical lower-row translation.`);
|
||||
await browser.close();
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
loadContent,
|
||||
validateContent,
|
||||
archiveText,
|
||||
} from "./archive-content.mjs";
|
||||
import { escapeHtml } from "../src/html.ts";
|
||||
|
||||
const content = await loadContent();
|
||||
test("all forty downloads match the shared content, including the UTF-8 BOM", async () => {
|
||||
for (const record of content.records) {
|
||||
assert.equal(
|
||||
(
|
||||
await readFile(
|
||||
new URL(
|
||||
`../public/archives/RHINE-LAB-${record.id}.txt`,
|
||||
import.meta.url,
|
||||
),
|
||||
"utf8",
|
||||
)
|
||||
).replace(/\r\n/g, "\n"),
|
||||
archiveText(record),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const invalidCases = [
|
||||
[
|
||||
"missing title",
|
||||
(c) => {
|
||||
delete c.records[0].title;
|
||||
},
|
||||
/records\[0\].title/,
|
||||
],
|
||||
[
|
||||
"blank abstract",
|
||||
(c) => {
|
||||
c.records[0].abstract = " ";
|
||||
},
|
||||
/abstract/,
|
||||
],
|
||||
[
|
||||
"duplicate ID",
|
||||
(c) => {
|
||||
c.records[1].id = "X-001";
|
||||
},
|
||||
/重复编号/,
|
||||
],
|
||||
[
|
||||
"reordered ID",
|
||||
(c) => {
|
||||
[c.records[0], c.records[1]] = [c.records[1], c.records[0]];
|
||||
},
|
||||
/X-001/,
|
||||
],
|
||||
[
|
||||
"unknown category",
|
||||
(c) => {
|
||||
c.records[0].category = "未知";
|
||||
},
|
||||
/未知分类/,
|
||||
],
|
||||
[
|
||||
"empty column",
|
||||
(c) => {
|
||||
// 把某一列的所有档案都挪到另一列 → 该列一条不剩,必须被拒。
|
||||
const victim = c.columns[0];
|
||||
const donor = c.columns[1];
|
||||
for (const record of c.records) if (record.category === victim) record.category = donor;
|
||||
},
|
||||
/至少要有一条档案/,
|
||||
],
|
||||
[
|
||||
"too few records",
|
||||
(c) => {
|
||||
c.records.length = 4;
|
||||
},
|
||||
/至少需要每个分类一条档案/,
|
||||
],
|
||||
[
|
||||
"null record",
|
||||
(c) => {
|
||||
c.records[0] = null;
|
||||
},
|
||||
/必须是档案对象/,
|
||||
],
|
||||
[
|
||||
"empty findings",
|
||||
(c) => {
|
||||
c.records[0].findings = [];
|
||||
},
|
||||
/findings/,
|
||||
],
|
||||
[
|
||||
"non-text findings",
|
||||
(c) => {
|
||||
c.records[0].findings = [42];
|
||||
},
|
||||
/findings/,
|
||||
],
|
||||
[
|
||||
"unsafe URL",
|
||||
(c) => {
|
||||
c.records[0].source = "javascript:alert(1)";
|
||||
},
|
||||
/HTTPS/,
|
||||
],
|
||||
[
|
||||
"invalid URL",
|
||||
(c) => {
|
||||
c.records[0].source = "example.com";
|
||||
},
|
||||
/HTTPS/,
|
||||
],
|
||||
[
|
||||
"duplicate categories",
|
||||
(c) => {
|
||||
c.categories[1] = c.categories[0];
|
||||
},
|
||||
/不能重复/,
|
||||
],
|
||||
[
|
||||
"reserved category",
|
||||
(c) => {
|
||||
c.categories[0] = "全部档案";
|
||||
},
|
||||
/全部档案/,
|
||||
],
|
||||
[
|
||||
"mismatched columns",
|
||||
(c) => {
|
||||
c.columns[0] = "其他";
|
||||
},
|
||||
/相同的五个分类/,
|
||||
],
|
||||
];
|
||||
for (const [name, mutate, error] of invalidCases) {
|
||||
test(`rejects ${name}`, () => {
|
||||
const invalid = structuredClone(content);
|
||||
mutate(invalid);
|
||||
assert.throws(() => validateContent(invalid), error);
|
||||
});
|
||||
}
|
||||
test("accepts independent filter and column order", () => {
|
||||
const edited = structuredClone(content);
|
||||
edited.categories.reverse();
|
||||
assert.equal(validateContent(edited), edited);
|
||||
});
|
||||
test("accepts unequal column counts instead of forcing eight each", () => {
|
||||
const edited = structuredClone(content);
|
||||
const want = [1, 12, 5, 2, 9]; // 合计 29,且每列都不同
|
||||
const pool = [...edited.records];
|
||||
edited.records = [];
|
||||
edited.columns.forEach((name, lane) => {
|
||||
for (let k = 0; k < want[lane]; k += 1) {
|
||||
const source = pool[lane * 3 + (k % 3)] ?? pool[0];
|
||||
edited.records.push({ ...source, category: name });
|
||||
}
|
||||
});
|
||||
edited.records.forEach((record, i) => {
|
||||
record.id = `X-${String(i + 1).padStart(3, "0")}`;
|
||||
});
|
||||
assert.equal(validateContent(edited), edited);
|
||||
assert.equal(edited.records.length, 29);
|
||||
edited.columns.forEach((name, lane) => {
|
||||
assert.equal(edited.records.filter((r) => r.category === name).length, want[lane]);
|
||||
});
|
||||
});
|
||||
|
||||
test("pads archive ids to the total once it exceeds 999", () => {
|
||||
const edited = structuredClone(content);
|
||||
const base = edited.records[0];
|
||||
edited.records = Array.from({ length: 1200 }, (_, i) => ({
|
||||
...structuredClone(base),
|
||||
category: edited.columns[i % edited.columns.length],
|
||||
id: `X-${String(i + 1).padStart(4, "0")}`,
|
||||
}));
|
||||
assert.equal(validateContent(edited), edited);
|
||||
});
|
||||
|
||||
test("plain-text punctuation stays literal in HTML and downloadable text", () => {
|
||||
const title = `<玻璃> & "实验" 'A'`;
|
||||
const edited = structuredClone(content);
|
||||
edited.records[0].title = title;
|
||||
validateContent(edited);
|
||||
assert.equal(
|
||||
escapeHtml(title),
|
||||
"<玻璃> & "实验" 'A'",
|
||||
);
|
||||
assert.ok(archiveText(edited.records[0]).includes(title));
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import {createRequire} from 'node:module';
|
||||
import assert from 'node:assert/strict';
|
||||
import {mkdir,writeFile} from 'node:fs/promises';
|
||||
const require=createRequire(import.meta.url);
|
||||
const {chromium}=require(process.env.PLAYWRIGHT_MODULE || 'playwright');
|
||||
const browser=await chromium.launch({channel:'msedge',headless:true});
|
||||
const page=await browser.newPage({viewport:{width:1920,height:1080}});
|
||||
const errors=[];page.on('pageerror',e=>errors.push(String(e)));
|
||||
await page.goto('http://127.0.0.1:5176/?scene=archive');
|
||||
await page.waitForFunction(()=>window.wallpaperPropertyListener&&document.querySelector('.wb-clock')?.textContent);
|
||||
async function apply(values){await page.evaluate(values=>window.wallpaperPropertyListener.applyUserProperties(Object.fromEntries(Object.entries(values).map(([k,value])=>[k,{value}]))),values);await page.waitForTimeout(650)}
|
||||
await apply({desktopmode:'workbench',boot:false,music:false,sound:false,reduced:true,hudtracking:false,uifrost:true,screenfinish:true,screengrain:20,screenfringe:20,screenvignette:20,task1:'记录本次实验结果',task2:'整理观测资料',task3:'完成今日工作'});
|
||||
await page.waitForTimeout(1500);
|
||||
const selectors=['.wb-overview','.wb-module','.brand','.wb-nav button','.relay-entry','.system-footer > span','.system-footer > button'];
|
||||
async function measure(){return page.evaluate(selectors=>selectors.map(s=>{const n=document.querySelector(s),r=n.getBoundingClientRect(),c=getComputedStyle(n);return {s,x:r.x,y:r.y,w:r.width,h:r.height,overflowX:c.overflowX,overflowY:c.overflowY}}),selectors)}
|
||||
|
||||
await mkdir('verification/ui-insets',{recursive:true});
|
||||
await apply({colortheme:'dark',hudparallax:false,selectionstyle:'original',audioreactive:false,idlebreathing:false});await page.waitForTimeout(2500);
|
||||
await apply({selectedindexaccent:false});await page.screenshot({path:'verification/ui-insets/dark-all-accent.png'});
|
||||
await apply({selectedindexaccent:true});await page.screenshot({path:'verification/ui-insets/dark-selected-accent.png'});
|
||||
await apply({colortheme:'light'});await page.waitForTimeout(2500);await page.screenshot({path:'verification/ui-insets/light-selected-accent.png'});
|
||||
assert.deepEqual(errors,[]);console.log('Dark label toggle rendered without page exceptions.');await browser.close();
|
||||
@@ -0,0 +1,154 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import * as THREE from "three";
|
||||
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
|
||||
import { decryptionFrame, DecryptionController } from "../src/decryption.ts";
|
||||
import { CardAppearance } from "../src/appearance.ts";
|
||||
import { frostedTransmissionLod, FROSTED_ROUGHNESS, CLEAR_ROUGHNESS } from "../src/glass-reveal.ts";
|
||||
|
||||
// Frost occupies the same fraction of a cover across viewport sizes. Clearing
|
||||
// must never briefly increase its blur, and both original endpoints survive.
|
||||
for (const scale of [0.5, 1, 2]) {
|
||||
const pixels = 400 * scale, width = 1920 * scale;
|
||||
assert.ok(Math.abs(2 ** frostedTransmissionLod(pixels, width, FROSTED_ROUGHNESS) / pixels - 0.016) < 1e-10);
|
||||
let prior = -Infinity;
|
||||
for (let i = 0; i <= 100; i++) {
|
||||
const roughness = CLEAR_ROUGHNESS + (FROSTED_ROUGHNESS - CLEAR_ROUGHNESS) * i / 100;
|
||||
const lod = frostedTransmissionLod(pixels, width, roughness);
|
||||
assert.ok(lod >= prior - 1e-10, "Clearing monotonically reduces blur");
|
||||
prior = lod;
|
||||
assert.equal(frostedTransmissionLod(pixels, width, roughness, 0), Math.log2(width) * roughness * (1.46 * 2 - 2));
|
||||
}
|
||||
assert.ok(Math.abs(frostedTransmissionLod(pixels, width, CLEAR_ROUGHNESS) - Math.log2(width) * CLEAR_ROUGHNESS * (1.46 * 2 - 2)) < 1e-10);
|
||||
}
|
||||
|
||||
const length = (frame) => frame.intervals.reduce((n, [a, b]) => n + b - a, 0);
|
||||
let previous = 0;
|
||||
for (let t = 34.24; t < 36.04; t += 0.001) {
|
||||
const f = decryptionFrame(t),
|
||||
current = length(f);
|
||||
assert.ok(current >= previous - 1e-10 && current <= 1);
|
||||
if (f.intervals.length)
|
||||
assert.ok(Math.abs(f.intervals[0][1] + f.intervals[1][0] - 1) < 1e-10);
|
||||
previous = current;
|
||||
}
|
||||
assert.deepEqual(decryptionFrame(36.04).intervals, [[0, 1]]);
|
||||
assert.equal(length(decryptionFrame(37.71)), 1);
|
||||
previous = 1;
|
||||
for (let t = 37.72; t < 38.84; t += 0.001) {
|
||||
const f = decryptionFrame(t),
|
||||
current = length(f);
|
||||
assert.ok(current <= previous + 1e-10 && current >= 0);
|
||||
assert.ok(Math.abs(f.intervals[0][0] + f.intervals[0][1] - 1) < 1e-10);
|
||||
assert.equal(f.clarity, 0);
|
||||
previous = current;
|
||||
}
|
||||
assert.equal(length(decryptionFrame(38.84)), 0);
|
||||
assert.equal(decryptionFrame(38.84).clarity, 0);
|
||||
assert.equal(decryptionFrame(39.56).clarity, 1);
|
||||
assert.ok(length(decryptionFrame(34.64)) > 0.5, "Joining is eased, not linear");
|
||||
const a = new DecryptionController();
|
||||
a.enter();
|
||||
a.update(3, false, false);
|
||||
assert.equal(a.frame.phase, "waiting");
|
||||
a.update(0, true, false);
|
||||
for (let i = 0; i < 120; i++) a.update(1 / 30, true, false);
|
||||
assert.equal(a.clarity, 1);
|
||||
a.leave();
|
||||
assert.equal(a.frame.intervals.length, 0);
|
||||
a.update(0.1, false, false);
|
||||
const returning = a.clarity;
|
||||
a.enter();
|
||||
a.update(0, true, false);
|
||||
assert.equal(a.clarity, returning);
|
||||
a.update(0.1, true, true);
|
||||
assert.equal(a.clarity, 1);
|
||||
assert.equal(a.frame.intervals.length, 0);
|
||||
a.select();
|
||||
assert.equal(a.clarity, 0);
|
||||
a.update(0, false, false, 35);
|
||||
assert.equal(a.frame.phase, "joining");
|
||||
a.update(0, false, false, 39.56);
|
||||
assert.equal(a.clarity, 1);
|
||||
a.update(0, false, false, 34);
|
||||
assert.equal(a.clarity, 0, "Reference seeking is reversible");
|
||||
|
||||
const appearance = new CardAppearance();
|
||||
const high = new THREE.MeshPhysicalMaterial({
|
||||
transmission: 0.9,
|
||||
thickness: 0.12,
|
||||
attenuationDistance: 2,
|
||||
});
|
||||
const low = high.clone();
|
||||
low.thickness = 0.25;
|
||||
appearance.register("Frosted_Polymer", high, low);
|
||||
const group = new THREE.Group(),
|
||||
mesh = new THREE.Mesh(new THREE.BoxGeometry(), high);
|
||||
mesh.userData.surface = "Frosted_Polymer";
|
||||
group.add(mesh);
|
||||
appearance.prepare(group);
|
||||
appearance.apply(group, 1);
|
||||
const shader = {
|
||||
uniforms: {},
|
||||
vertexShader: "#include <begin_vertex>\n#include <project_vertex>",
|
||||
fragmentShader: "#include <transmission_pars_fragment>\n#include <color_fragment>\n#include <roughnessmap_fragment>",
|
||||
};
|
||||
mesh.material.onBeforeCompile(shader);
|
||||
assert.ok(shader.vertexShader.includes("vArchiveProjectedAxis = 1.85"));
|
||||
assert.ok(shader.fragmentShader.includes("float lod = archiveTransmissionLod(roughness, ior, transmissionSamplerSize);"));
|
||||
const part = new THREE.Group();
|
||||
group.add(part);
|
||||
part.add(mesh);
|
||||
appearance.setClarity(group, 1);
|
||||
assert.equal(shader.uniforms.archiveClarity.value, 1);
|
||||
assert.ok(Math.abs(mesh.material.thickness - 0.018) < 1e-10);
|
||||
appearance.setClarity(group, 0);
|
||||
assert.equal(shader.uniforms.archiveClarity.value, 0);
|
||||
assert.equal(mesh.material.thickness, 0.12);
|
||||
|
||||
const bytes = await readFile(
|
||||
new URL("../public/assets/archive-assembly.glb", import.meta.url),
|
||||
);
|
||||
const asset = (
|
||||
await new GLTFLoader().parseAsync(
|
||||
bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength),
|
||||
"",
|
||||
)
|
||||
).scene;
|
||||
asset.updateMatrixWorld(true);
|
||||
let interiors = 0,
|
||||
maxDepth = -Infinity,
|
||||
minDepth = Infinity;
|
||||
asset.traverse((mesh) => {
|
||||
if (
|
||||
!mesh.isMesh ||
|
||||
!["optical-core", "optical-lenses"].includes(mesh.userData.assemblyPart)
|
||||
)
|
||||
return;
|
||||
const box = new THREE.Box3().setFromObject(mesh);
|
||||
minDepth = Math.min(minDepth, box.min.z);
|
||||
maxDepth = Math.max(maxDepth, box.max.z);
|
||||
interiors++;
|
||||
assert.ok(
|
||||
box.min.z > -0.038 && box.max.z < 0.174,
|
||||
"Interior stays between substrate and cover",
|
||||
);
|
||||
});
|
||||
assert.ok(interiors >= 6);
|
||||
// Perspective depth resolution, at the actual detail distance and 24-bit depth.
|
||||
const step = (near, distance) =>
|
||||
(distance * distance * (300 - near)) / (300 * near * (2 ** 24 - 1));
|
||||
assert.ok(step(5, 72) < step(0.1, 72) / 50);
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
passed: true,
|
||||
interiorMeshes: interiors,
|
||||
interiorDepth: [minDepth, maxDepth],
|
||||
depthStepBefore: step(0.1, 72),
|
||||
depthStepAfter: step(5, 72),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,55 @@
|
||||
// Compare the previous production build with the current one on local HTTP.
|
||||
// BASELINE_DIST defaults to .tools/issues-before; neither build is modified.
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer } from 'node:http';
|
||||
import { readFile, mkdir, writeFile } from 'node:fs/promises';
|
||||
import { resolve, extname, sep } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
const { chromium } = await import(process.env.PLAYWRIGHT_MODULE ? pathToFileURL(resolve(process.env.PLAYWRIGHT_MODULE)).href : 'playwright');
|
||||
const baseline = resolve(process.env.BASELINE_DIST || '.tools/issues-before');
|
||||
const current = resolve('dist');
|
||||
const out = resolve('.tools/issues');await mkdir(out,{recursive:true});
|
||||
const mime={'.html':'text/html','.js':'text/javascript','.css':'text/css','.woff2':'font/woff2','.svg':'image/svg+xml','.glb':'model/gltf-binary','.ogg':'audio/ogg','.json':'application/json'};
|
||||
const serve = async(root,port)=>{
|
||||
const server=createServer(async(req,res)=>{try {
|
||||
const url=new URL(req.url,'http://localhost');const file=resolve(root,'.'+(url.pathname==='/'?'/index.html':decodeURIComponent(url.pathname)));
|
||||
if(!file.startsWith(root+sep))throw Error('path');const body=await readFile(file);
|
||||
res.writeHead(200,{'Content-Type':mime[extname(file)]||'application/octet-stream'}).end(body);
|
||||
}catch{res.writeHead(404).end()}});
|
||||
await new Promise(r=>server.listen(port,'127.0.0.1',r));return server;
|
||||
};
|
||||
const servers=await Promise.all([serve(baseline,0),serve(current,0)]);
|
||||
const browser=await chromium.launch({channel:'chrome',headless:true,args:['--use-angle=d3d11','--enable-gpu','--ignore-gpu-blocklist']});
|
||||
const report={cases:[],errors:[]};
|
||||
try {
|
||||
for(const [name,port] of [['baseline',servers[0].address().port],['current',servers[1].address().port]]) {
|
||||
const context=await browser.newContext({viewport:{width:1920,height:1080},serviceWorkers:'block',reducedMotion:'no-preference'});
|
||||
const page=await context.newPage();page.on('pageerror',e=>report.errors.push(e.message));
|
||||
await page.goto(`http://127.0.0.1:${port}/`);
|
||||
await page.waitForFunction(()=>window.rhine?.stats().ready);
|
||||
await page.evaluate(()=>document.fonts.ready);
|
||||
const fonts=await page.evaluate(()=>performance.getEntriesByType('resource').filter(e=>e.name.endsWith('.woff2')).map(e=>({url:new URL(e.name).pathname,bytes:e.decodedBodySize})));
|
||||
const result={name,entryFontBytes:fonts.reduce((n,f)=>n+f.bytes,0),entryFontRequests:fonts.length};report.cases.push(result);
|
||||
await page.goto(`http://127.0.0.1:${port}/?time=8.48&freeze=1`);
|
||||
await page.waitForFunction(()=>window.rhine?.stats().ready&&!document.querySelector('#loading'));
|
||||
await page.evaluate(()=>document.fonts.ready);
|
||||
result.brand=await page.evaluate(()=>['.brand h1','.brand > div','.brand p'].map(selector=>{
|
||||
const el=document.querySelector(selector),r=document.createRange();r.selectNodeContents(el);const rect=r.getBoundingClientRect();
|
||||
return {selector,text:el.textContent,width:rect.width,height:rect.height,x:rect.x,y:rect.y,font:getComputedStyle(el).font};
|
||||
}));
|
||||
await page.screenshot({path:resolve(out,`brand-${name}.png`),clip:{x:45,y:100,width:300,height:135}});
|
||||
await page.goto(`http://127.0.0.1:${port}/?scene=detail`);
|
||||
await page.waitForFunction(()=>window.rhine?.stats().ready&&!document.querySelector('#loading'));
|
||||
await page.waitForFunction(()=>window.rhine.stats().decryption.clarity===1);
|
||||
await page.evaluate(()=>document.fonts.ready);
|
||||
await page.waitForFunction(()=>!document.querySelector('.document-redaction-window'));
|
||||
await page.screenshot({path:resolve(out,`detail-${name}.png`)});
|
||||
result.document=await page.locator('.detail-content').evaluate(el=>({width:el.clientWidth,height:el.clientHeight,scrollWidth:el.scrollWidth,scrollHeight:el.scrollHeight}));
|
||||
await context.close();
|
||||
}
|
||||
report.fontByteReduction=1-report.cases[1].entryFontBytes/report.cases[0].entryFontBytes;
|
||||
assert.ok(report.fontByteReduction>.9);
|
||||
for(let i=0;i<3;i++)assert.ok(Math.abs(report.cases[1].brand[i].width-report.cases[0].brand[i].width)<.1,'Calibrated brand width must survive the font update');
|
||||
assert.equal(report.cases[1].document.scrollWidth,report.cases[1].document.width);
|
||||
assert.deepEqual(report.errors,[]);console.log(JSON.stringify(report,null,2));
|
||||
} finally {await writeFile(resolve(out,'font-loading.json'),JSON.stringify(report,null,2));await browser.close();await Promise.all(servers.map(s=>new Promise(r=>s.close(r))));}
|
||||
@@ -0,0 +1,50 @@
|
||||
// Migrate an installed pre-split-font release to this build, then test offline.
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer } from 'node:http';
|
||||
import { readFile, mkdir, writeFile } from 'node:fs/promises';
|
||||
import { resolve, extname, sep } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
const { chromium } = await import(process.env.PLAYWRIGHT_MODULE ? pathToFileURL(resolve(process.env.PLAYWRIGHT_MODULE)).href : 'playwright');
|
||||
const oldRoot=resolve(process.env.BASELINE_DIST || '.tools/issues-before'),newRoot=resolve('dist');
|
||||
const metadata=JSON.parse(await readFile(resolve(newRoot,'pwa-build.json'),'utf8'));
|
||||
const report={release:metadata.version,checks:[],errors:[]};let deployed=false;
|
||||
const mime={'.html':'text/html','.js':'text/javascript','.css':'text/css','.json':'application/json','.woff2':'font/woff2','.svg':'image/svg+xml','.glb':'model/gltf-binary','.ogg':'audio/ogg','.webmanifest':'application/manifest+json'};
|
||||
const server=createServer(async(req,res)=>{try {
|
||||
const root=deployed?newRoot:oldRoot,url=new URL(req.url,'http://localhost');
|
||||
const file=resolve(root,'.'+(url.pathname==='/'?'/index.html':decodeURIComponent(url.pathname)));
|
||||
if(!file.startsWith(root+sep))throw Error('path');const body=await readFile(file);
|
||||
res.writeHead(200,{'Content-Type':mime[extname(file)]||'application/octet-stream','Cache-Control':'no-cache'}).end(body);
|
||||
}catch{res.writeHead(404).end()}});
|
||||
await new Promise(r=>server.listen(5198,'127.0.0.1',r));
|
||||
const browser=await chromium.launch({channel:process.env.REVIEW_CHANNEL||'chrome',headless:true,args:['--use-angle=d3d11','--enable-gpu','--ignore-gpu-blocklist']});
|
||||
try {
|
||||
const context=await browser.newContext({viewport:{width:1440,height:900}});
|
||||
await context.addInitScript(()=>{if(!localStorage.getItem('rhine-settings'))localStorage.setItem('rhine-settings',JSON.stringify({sound:false,music:false,reduced:true}))});
|
||||
const page=await context.newPage();page.on('pageerror',e=>report.errors.push(e.message));
|
||||
const ready=()=>page.waitForFunction(()=>window.rhine?.stats().ready&&!document.querySelector('#loading')&&navigator.serviceWorker.controller&&document.documentElement.dataset.offlineReady==='true',null,{timeout:120000});
|
||||
await page.goto('http://127.0.0.1:5198/');await ready();
|
||||
await page.evaluate(()=>localStorage.setItem('rhine-saved','["X-001","X-009"]'));
|
||||
const oldKeys=await page.evaluate(()=>caches.keys());
|
||||
assert.ok(await page.evaluate(()=>caches.match('/fonts/MiSans-Regular.woff2').then(Boolean)));
|
||||
deployed=true;
|
||||
await page.evaluate(async()=>{await(await navigator.serviceWorker.getRegistration()).update()});
|
||||
await page.waitForFunction(async()=>Boolean((await navigator.serviceWorker.getRegistration())?.waiting),null,{timeout:120000});
|
||||
assert.equal(await page.locator('.entry-start').count(),0);
|
||||
await page.locator('#pwa-update-notice [data-pwa-action="update"]').click();await page.waitForLoadState('load');await ready();
|
||||
assert.equal(await page.evaluate(()=>window.rhine.stats().startup),'started');
|
||||
assert.equal(await page.evaluate(()=>localStorage.getItem('rhine-saved')),'["X-001","X-009"]');
|
||||
assert.equal(await page.evaluate(()=>JSON.parse(localStorage.getItem('rhine-settings')).sound),false);
|
||||
const newKeys=await page.evaluate(()=>caches.keys());assert.ok(newKeys.some(k=>k.endsWith(metadata.version)));assert.ok(oldKeys.every(k=>!newKeys.includes(k)));
|
||||
assert.equal(await page.evaluate(()=>caches.match('/fonts/MiSans-Regular.woff2').then(Boolean)),false);
|
||||
report.checks.push('Previous complete release updates atomically; obsolete whole fonts removed; bookmarks and preferences retained');
|
||||
// Enable audio only for the next entry, then prove its cached resources work.
|
||||
await page.evaluate(()=>{const p=JSON.parse(localStorage.getItem('rhine-settings'));p.sound=true;p.music=true;localStorage.setItem('rhine-settings',JSON.stringify(p))});
|
||||
await context.setOffline(true);await page.reload();await page.waitForFunction(()=>window.rhine?.stats().startup==='waiting');
|
||||
await page.locator('.entry-start').click();await ready();
|
||||
assert.equal(await page.evaluate(()=>window.rhine.stats().audio.tracks),3);
|
||||
await page.locator('.read-file').click();await page.waitForFunction(()=>window.rhine.stats().decryption.clarity===1);
|
||||
await page.locator('.viewer-open').click();await page.waitForFunction(()=>JSON.parse(document.querySelector('.model-viewer')?.dataset.stats||'{}').ready);
|
||||
await page.locator('[data-viewer="explode"]').click();await page.waitForFunction(()=>JSON.parse(document.querySelector('.model-viewer').dataset.stats).spread===1);
|
||||
report.checks.push('Updated release enters offline with all three music tracks, split fonts, hashed main/viewer models and explosion');
|
||||
assert.deepEqual(report.errors,[]);console.log(JSON.stringify(report,null,2));
|
||||
} finally {await mkdir('.tools/issues',{recursive:true});await writeFile('.tools/issues/font-update.json',JSON.stringify(report,null,2));await browser.close();await new Promise(r=>server.close(r));}
|
||||
@@ -0,0 +1,32 @@
|
||||
import assert from "node:assert/strict";
|
||||
import {readFile} from "node:fs/promises";
|
||||
import * as T from "three";
|
||||
import {GLTFLoader} from "three/addons/loaders/GLTFLoader.js";
|
||||
async function load(path) {
|
||||
const b=await readFile(new URL(path,import.meta.url));
|
||||
const s=(await new GLTFLoader().parseAsync(b.buffer.slice(b.byteOffset,b.byteOffset+b.byteLength),"")).scene;
|
||||
s.updateMatrixWorld(true);return s;
|
||||
}
|
||||
const [chosen,previous,current]=await Promise.all([
|
||||
load("../reference/internal-study/assembly-before.glb"),
|
||||
load("../reference/ring-study/assembly-before.glb"),
|
||||
load("../public/assets/archive-assembly.glb"),
|
||||
]);
|
||||
function collect(scene,interior) {
|
||||
const result=new Map();
|
||||
scene.traverse(m=>{
|
||||
if(!m.isMesh)return;
|
||||
const isInterior=["optical-core","optical-lenses"].includes(m.userData.assemblyPart);
|
||||
if(isInterior!==interior)return;
|
||||
const p=m.geometry.attributes.position,n=m.geometry.attributes.normal,nm=new T.Matrix3().getNormalMatrix(m.matrixWorld),rows=[];
|
||||
for(let i=0;i<p.count;i++) {
|
||||
const v=new T.Vector3().fromBufferAttribute(p,i).applyMatrix4(m.matrixWorld);
|
||||
const normal=new T.Vector3().fromBufferAttribute(n,i).applyNormalMatrix(nm);
|
||||
rows.push([...v.toArray(),...normal.toArray()].map(x=>x.toFixed(5)).join(","));
|
||||
}
|
||||
result.set(m.userData.assemblyPart+":"+m.material.name.replace(/\.\d+$/,""),{rows:rows.sort(),color:m.material.color.toArray().map(x=>x.toFixed(6)),roughness:m.material.roughness,metalness:m.material.metalness});
|
||||
});return result;
|
||||
}
|
||||
assert.deepEqual(collect(current,true),collect(chosen,true),"Restore the chosen first-version interior geometry, normals and source materials");
|
||||
assert.deepEqual(collect(current,false),collect(previous,false),"Keep the refined outer case unchanged");
|
||||
console.log(JSON.stringify({passed:true,interior:"matches chosen first version",outerCase:"unchanged",precision:1e-5},null,2));
|
||||
@@ -0,0 +1,14 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {SpectrumEnvelope} from '../src/archive-play-motion.ts';
|
||||
const ordinary=new SpectrumEnvelope(),filtered=new SpectrumEnvelope();
|
||||
const tone=Array(128).fill(.16);
|
||||
for(let i=0;i<120;i++){const t=i/60;filtered.ignoreLocalSound(t+.4);ordinary.ingest(tone,t);filtered.ingest(tone,t);ordinary.update(1/60,t,true);filtered.update(1/60,t,true)}
|
||||
assert.ok(ordinary.bands.activity>.99,'System-mixed interaction audio reproduces the false music onset');
|
||||
assert.equal(filtered.bands.activity,0);assert.equal(filtered.bands.low,0);
|
||||
for(let i=180;i<360;i++){const t=i/60;filtered.ingest(tone,t);filtered.update(1/60,t,true)}
|
||||
assert.ok(filtered.bands.activity>.99,'External sustained music still starts');
|
||||
for(let i=360;i<480;i++){const t=i/60;filtered.ignoreLocalSound(t+.4);filtered.ingest(tone,t);filtered.update(1/60,t,true)}
|
||||
assert.ok(filtered.bands.activity>.99,'Dragging during established music does not disable music');
|
||||
for(let i=480;i<1080;i++)filtered.update(1/60,i/60,true);
|
||||
assert.ok(filtered.bands.activity<.001,'Missing audio returns to silence');
|
||||
console.log('Reproduced false onset; own sounds ignored from silence, sustained music and release passed.');
|
||||
@@ -0,0 +1,86 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { columnFiles, fileLocation } from "../src/data.ts";
|
||||
import {
|
||||
fileAtCell,
|
||||
selectionCell,
|
||||
visibleCell,
|
||||
poolCell,
|
||||
cellKey,
|
||||
LOOP_COLUMNS,
|
||||
LOOP_ROWS,
|
||||
wrap,
|
||||
} from "../src/archive-loop.ts";
|
||||
|
||||
// 每列的档案在零点两侧循环复用。列内条数不再固定为八条,
|
||||
// 所以用该列自己的 files.length 取模,而不是写死的 8。
|
||||
for (let lane = -23; lane <= 23; lane++) {
|
||||
const files = columnFiles(wrap(lane, 5));
|
||||
for (let row = -35; row <= 35; row++) {
|
||||
assert.equal(fileAtCell({ lane, row }), files[wrap(row - 12, files.length)]);
|
||||
}
|
||||
}
|
||||
let checks = 0;
|
||||
for (const direction of [-1, 1]) {
|
||||
let cell = { lane: 2, row: 12 };
|
||||
let index = fileAtCell(cell);
|
||||
const memory = Array.from({ length: 5 }, (_, lane) => columnFiles(lane)[0]);
|
||||
for (let step = 0; step < 10000; step++) {
|
||||
const axis = step % 17 < 10 ? "row" : "lane";
|
||||
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)];
|
||||
const next = selectionCell(index, cell, { axis, direction });
|
||||
assert.equal(
|
||||
next[axis] - cell[axis],
|
||||
direction,
|
||||
"Crossing a seam must move exactly one cell in the requested direction",
|
||||
);
|
||||
assert.equal(
|
||||
fileAtCell(next),
|
||||
index,
|
||||
"Selected physical cell must contain the requested document",
|
||||
);
|
||||
memory[fileLocation(index).lane] = index;
|
||||
cell = next;
|
||||
checks++;
|
||||
}
|
||||
}
|
||||
for (const center of [
|
||||
{ lane: 2, row: 12 },
|
||||
{ lane: -8.3, row: -19.2 },
|
||||
{ lane: 10002.49, row: -32001.49 },
|
||||
]) {
|
||||
const cells = Array.from({ length: LOOP_COLUMNS * LOOP_ROWS }, (_, i) =>
|
||||
visibleCell(i, center),
|
||||
);
|
||||
assert.equal(new Set(cells.map(cellKey)).size, LOOP_COLUMNS * LOOP_ROWS);
|
||||
const lanes = [...new Set(cells.map((c) => c.lane))].sort((a, b) => a - b);
|
||||
const rows = [...new Set(cells.map((c) => c.row))].sort((a, b) => a - b);
|
||||
assert.equal(lanes.length, LOOP_COLUMNS);
|
||||
assert.equal(rows.length, LOOP_ROWS);
|
||||
assert.equal(lanes.at(-1) - lanes[0], LOOP_COLUMNS - 1);
|
||||
assert.equal(rows.at(-1) - rows[0], LOOP_ROWS - 1);
|
||||
assert.ok(lanes[0] < center.lane - 3 && lanes.at(-1) > center.lane + 3);
|
||||
assert.ok(rows[0] < center.row - 14 && rows.at(-1) > center.row + 14);
|
||||
}
|
||||
for (let i = 0; i < 160; i++) {
|
||||
assert.deepEqual(
|
||||
poolCell(i),
|
||||
{ lane: Math.floor(i / 32), row: i % 32 },
|
||||
"Reference-animation instance order is preserved",
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
directionalMoves: checks,
|
||||
poolSize: LOOP_COLUMNS * LOOP_ROWS,
|
||||
referenceInstances: 160,
|
||||
checks: "passed",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,147 @@
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
archiveWave,
|
||||
extraction,
|
||||
selectionWave,
|
||||
baselineSelectionWave,
|
||||
rippleEnvelope,
|
||||
settlingWave,
|
||||
damp,
|
||||
idleWave,
|
||||
} from "../src/motion.ts";
|
||||
import { selectionWave as historicalWave } from "../reference/baseline-motion.ts";
|
||||
|
||||
// The selected production motion is the original signed wave, including troughs.
|
||||
let baselineTrough = false;
|
||||
for (let frame = -1; frame <= 200; frame++) {
|
||||
for (let distance = 0; distance <= 32; distance += 0.5) {
|
||||
const restored = baselineSelectionWave(distance, frame / 60);
|
||||
assert.equal(restored, historicalWave(distance, frame / 60));
|
||||
baselineTrough ||= restored < -0.01;
|
||||
}
|
||||
}
|
||||
assert.ok(baselineTrough, "The original negative trough is restored");
|
||||
|
||||
let idleRange = 0;
|
||||
for (let lane = 0; lane < 5; lane++) {
|
||||
for (let row = 0; row < 32; row++) {
|
||||
for (let frame = 0; frame < 60 * 13; frame++) {
|
||||
const a = idleWave(row, lane, frame / 60);
|
||||
const b = idleWave(row, lane, (frame + 1) / 60);
|
||||
idleRange = Math.max(idleRange, Math.abs(a));
|
||||
assert.ok(
|
||||
Math.abs(a) < 3.7 * 0.03,
|
||||
"Idle lift stays below 3% of card height",
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(b - a) * (1080 / 7.33) < 0.21,
|
||||
"Idle motion remains subpixel per frame",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(idleRange > 0.075, "Idle field remains perceptible without input");
|
||||
|
||||
const peak = (t) =>
|
||||
Array.from({ length: 32 }, (_, row) => archiveWave(row, 2, t)).reduce(
|
||||
(best, y, row, values) => (y > values[best] ? row : best),
|
||||
0,
|
||||
);
|
||||
assert.ok(peak(23.3) > peak(22.7) + 6, "First crest must travel across rows");
|
||||
assert.ok(peak(24.8) < peak(24.2) - 8, "Second crest must return across rows");
|
||||
let maxFrameDelta = 0;
|
||||
for (let frame = 550; frame < 800; frame++) {
|
||||
for (let row = 0; row < 32; row++)
|
||||
for (let lane = 0; lane < 5; lane++) {
|
||||
const a = archiveWave(row, lane, frame / 25);
|
||||
const b = archiveWave(row, lane, (frame + 1) / 25);
|
||||
assert.ok(Number.isFinite(a));
|
||||
maxFrameDelta = Math.max(maxFrameDelta, Math.abs(b - a));
|
||||
}
|
||||
}
|
||||
assert.ok(maxFrameDelta < 0.7, "25 fps samples must not teleport");
|
||||
assert.ok(
|
||||
Math.abs(extraction(26.6) - extraction(27.2)) < 0.01,
|
||||
"Pause between extraction phases",
|
||||
);
|
||||
assert.ok(extraction(29) > 3 && extraction(25.1) === 0);
|
||||
assert.ok(
|
||||
Math.abs(settlingWave(2, 26.1) - settlingWave(2, 26.5)) > 0.01,
|
||||
"Neighbors keep moving during the first extraction hold",
|
||||
);
|
||||
assert.ok(selectionWave(8, 1) > 0.1, "Click ripple reaches neighboring rows");
|
||||
for (let frame = 0; frame <= 200; frame++) {
|
||||
for (let distance = 0; distance <= 32; distance += 0.5) {
|
||||
const y = selectionWave(distance, frame / 60);
|
||||
const age = frame / 60;
|
||||
const ramp = Math.max(0, Math.min(1, age / 0.2));
|
||||
const original =
|
||||
0.8 *
|
||||
ramp ** 3 *
|
||||
(10 + ramp * (-15 + 6 * ramp)) *
|
||||
Math.exp(-age * 1.15) *
|
||||
Math.cos((distance - age * 8) * 0.58) *
|
||||
Math.exp(-0.5 * ((distance - age * 8) / 3.4) ** 2);
|
||||
assert.ok(
|
||||
y >= 0 && y <= 0.8,
|
||||
"Selection pulse cannot create a negative trough",
|
||||
);
|
||||
if (age <= 3.2 && original > 0)
|
||||
assert.ok(
|
||||
Math.abs(y - original) < 1e-12,
|
||||
"Positive crests retain the baseline amplitude and timing",
|
||||
);
|
||||
}
|
||||
}
|
||||
const ripple = (distance, age) =>
|
||||
selectionWave(distance, age) * rippleEnvelope(distance, age);
|
||||
for (let frame = 0; frame <= 200; frame++) {
|
||||
assert.equal(
|
||||
ripple(0, frame / 60),
|
||||
0,
|
||||
"The selected source cannot bounce on its own ripple",
|
||||
);
|
||||
}
|
||||
for (const distance of [3, 5, 8, 12]) {
|
||||
const crestTime = distance / 8;
|
||||
assert.equal(
|
||||
ripple(distance, crestTime),
|
||||
selectionWave(distance, crestTime),
|
||||
"The outward crest keeps its strength away from the source",
|
||||
);
|
||||
const edge = (distance - Math.PI / (2 * 0.58)) / 8;
|
||||
const epsilon = 1e-5;
|
||||
const velocity =
|
||||
(ripple(distance, edge + epsilon) - ripple(distance, edge - epsilon)) /
|
||||
(2 * epsilon);
|
||||
assert.ok(
|
||||
Math.abs(velocity) < 0.001,
|
||||
"Ripple edges approach rest without a velocity snap",
|
||||
);
|
||||
}
|
||||
const coarse = { value: 5, velocity: -2 },
|
||||
fine = { ...coarse };
|
||||
for (let i = 0; i < 30; i++) damp(coarse, -3, 4, 1 / 30);
|
||||
for (let i = 0; i < 120; i++) damp(fine, -3, 4, 1 / 120);
|
||||
assert.ok(
|
||||
Math.abs(coarse.value - fine.value) < 1e-9,
|
||||
"Spring must be frame-rate independent",
|
||||
);
|
||||
const before = coarse.value;
|
||||
damp(coarse, 6, 4, 1 / 120);
|
||||
assert.ok(
|
||||
Math.abs(coarse.value - before) < 0.05,
|
||||
"Retargeting must preserve position continuity",
|
||||
);
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
forwardPeaks: [peak(22.7), peak(23.3)],
|
||||
returnPeaks: [peak(24.2), peak(24.8)],
|
||||
maxFrameDelta,
|
||||
checks: "passed",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,52 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import ts from 'typescript';
|
||||
import vm from 'node:vm';
|
||||
async function load(file) {const source=ts.transpileModule(readFileSync(file,'utf8'),{compilerOptions:{module:ts.ModuleKind.ESNext,target:ts.ScriptTarget.ES2022}}).outputText;return import(`data:text/javascript;base64,${Buffer.from(source).toString('base64')}`)}
|
||||
const {SpectrumEnvelope,RelayRound,musicDisplacement,quietBands}=await load('src/archive-play-motion.ts');
|
||||
const e=new SpectrumEnvelope(), samples=Array(128).fill(0);samples.fill(1,64,72);e.ingest(samples,0);
|
||||
let bands=e.update(.1,.1,true);assert.ok(bands.low>.5);assert.equal(bands.mid,0);assert.equal(bands.high,0);
|
||||
for(let i=1;i<=150;i++) bands=e.update(.05,i*.05,true);
|
||||
assert.ok(bands.low<.0001&&bands.activity<.001,'Missing callbacks fade to original motion');
|
||||
e.ingest(Array(128).fill(NaN),8);bands=e.update(.1,8,true);assert.ok(Number.isFinite(bands.low));
|
||||
assert.equal(musicDisplacement(1,2,3,quietBands(),2),0);
|
||||
for(let row=-20;row<20;row++) {const n=musicDisplacement(row,3,1,{low:1,mid:1,high:1,activity:1},20);assert.ok(n>=0&&n<=1.8)}
|
||||
const r=new RelayRound();r.start();r.aim('1:2','normal');r.tick(100,true);assert.equal(r.remaining,6);assert.equal(r.hit('1:2'),true);assert.equal(r.score,1);assert.equal(r.hit('1:2'),false,'Duplicate hit cannot score twice');r.aim('1:3','normal');r.hit('wrong');assert.equal(r.status,'over');r.start();assert.equal(r.score,0);r.aim('1:2','quick');for(let i=0;i<50;i++)r.tick(.1,false);assert.equal(r.status,'over');r.stop();assert.equal(r.target,null);
|
||||
let callback;const window={dispatchEvent(){},wallpaperRegisterAudioListener:fn=>callback=fn};vm.runInNewContext(readFileSync('wallpaper/host.js','utf8'),{window,Event,CustomEvent,performance:{now:()=>2000}});assert.ok(callback);callback(Array(128).fill(4));assert.equal(window.rhineWallpaperSpectrum.samples[0],1);assert.equal(window.rhineWallpaperSpectrum.time,2);
|
||||
const {openingShowsDetail,ARRAY_OPENING_END}=await load('src/wallpaper-opening.ts');assert.equal(openingShowsDetail('auto',true),false);assert.equal(openingShowsDetail('auto',false),true);assert.equal(openingShowsDetail('show',true),true);assert.equal(openingShowsDetail('skip',false),false);assert.ok(ARRAY_OPENING_END<26);
|
||||
console.log('Spectrum stereo/clamping/decay, relay pause/scoring/retry/timeout, host callback and opening policy passed.');
|
||||
const project=JSON.parse(readFileSync('wallpaper/project.json','utf8')),props=project.general.properties;
|
||||
assert.equal(project.general.supportsaudioprocessing,true,'WE reads audio support from general');
|
||||
assert.equal(Object.hasOwn(project,'supportsaudioprocessing'),false,'Root-level flag is not recognized by the host');
|
||||
assert.equal(Object.values(props).filter(p=>p.type==='group').length,9);
|
||||
function visible(key,override={}){const context=structuredClone(props);for(const [k,v] of Object.entries(override))context[k].value=v;return !props[key].condition||vm.runInNewContext(props[key].condition,context)}
|
||||
assert.equal(visible('groupworkbench',{desktopmode:'archive'}),false);
|
||||
assert.equal(visible('reactiveintensity',{audioreactive:false}),false);
|
||||
assert.equal(visible('gamepace',{showgame:false}),false);
|
||||
assert.equal(visible('openingdetail',{boot:false}),false);
|
||||
for(const key of Object.keys(props))visible(key);
|
||||
assert.equal(visible('customwallpaperfile',{customwallpaper:false}),false);
|
||||
assert.equal(visible('customwallpaperfile',{customwallpaper:true}),true);
|
||||
console.log('Nine native groups and all display conditions passed.');
|
||||
|
||||
const {RhythmMotion,rhythmDisplacement}=await load('src/archive-play-motion.ts');
|
||||
const rhythm=new RhythmMotion(); let motion;
|
||||
const tone={low:0,mid:.5,high:0,activity:1};
|
||||
for(let i=0;i<300;i++)motion=rhythm.update(tone,i/60,1/60,'wave');
|
||||
const displacement=(x,b=tone,t=5)=>rhythmDisplacement(10,2,t,b,1,motion,x);
|
||||
assert.ok(displacement(.5)>.15,'Sustained midrange remains visible after five seconds');
|
||||
assert.ok(displacement(.5)>displacement(0)*10&&displacement(.5)>displacement(1)*10,'Midrange lives in the screen center');
|
||||
assert.ok(displacement(0,{...tone,low:.5,mid:0})>.15,'Bass lives on the left');
|
||||
assert.ok(displacement(1,{...tone,high:.5,mid:0})>.15,'Treble lives on the right');
|
||||
assert.ok(Math.abs(displacement(.499)-displacement(.501))<.001,'Band boundary is continuous');
|
||||
assert.equal(displacement(.5,quietBands()),0,'Silence has no new motion');
|
||||
for(let i=300;i<600;i++)motion=rhythm.update(tone,i/60,1/60,'lift');
|
||||
for(const key of ['low','mid','high']) {
|
||||
const b={...quietBands(),[key]:.5,activity:1};
|
||||
const samples=Array.from({length:60},(_,i)=>rhythmDisplacement(10,2,10+i/60,b,1,motion));
|
||||
assert.ok(Math.min(...samples)>0,'All sustained bands drive B continuously');
|
||||
assert.ok(Math.max(...samples)-Math.min(...samples)>.01,'B keeps travelling during sustained music');
|
||||
}
|
||||
assert.equal(rhythmDisplacement(10,2,20,quietBands(),1,motion),0);
|
||||
for(let i=0;i<200;i++){motion=rhythm.update(tone,i/60,1/60,i%2?'wave':'legacy');assert.ok(Math.abs(Object.values(motion.style).reduce((a,b)=>a+b,0)-1)<1e-10)}
|
||||
console.log('Continuous spectrum position, sustained notes, smooth bands, travelling layers, silence and interrupted style blend passed.');
|
||||
@@ -0,0 +1,58 @@
|
||||
// Preserve an earlier production dist, then set PWA_PREVIOUS_DIST to its path.
|
||||
import assert from 'node:assert/strict';
|
||||
import {createServer} from 'node:http';
|
||||
import {readFile, writeFile, mkdir} from 'node:fs/promises';
|
||||
import {resolve, extname, sep} from 'node:path';
|
||||
import {pathToFileURL} from 'node:url';
|
||||
const {chromium}=await import(process.env.PLAYWRIGHT_MODULE?pathToFileURL(resolve(process.env.PLAYWRIGHT_MODULE)).href:'playwright');
|
||||
if(!process.env.PWA_PREVIOUS_DIST) throw Error('Set PWA_PREVIOUS_DIST to an earlier built release.');
|
||||
const oldRoot=resolve(process.env.PWA_PREVIOUS_DIST),newRoot=resolve('dist');
|
||||
const metadata=JSON.parse(await readFile(resolve(newRoot,'pwa-build.json'),'utf8'));
|
||||
let deployed=false,broken=false;
|
||||
const mime={'.html':'text/html','.js':'text/javascript','.css':'text/css','.json':'application/json','.webmanifest':'application/manifest+json','.svg':'image/svg+xml','.png':'image/png','.woff2':'font/woff2','.txt':'text/plain','.glb':'model/gltf-binary','.ogg':'audio/ogg'};
|
||||
const server=createServer(async(req,res)=>{try {
|
||||
const root=deployed?newRoot:oldRoot,url=new URL(req.url,'http://localhost');
|
||||
const path=decodeURIComponent(url.pathname==='/'?'/index.html':url.pathname),file=resolve(root,'.'+path);
|
||||
if(!file.startsWith(root+sep))throw Error('path');
|
||||
if(broken&&path==='/icons/icon-192.png'){res.writeHead(503).end();return}
|
||||
let body=await readFile(file);
|
||||
if(broken&&path==='/sw.js')body=Buffer.from(body.toString().replace(metadata.version,metadata.version+'-broken'));
|
||||
res.writeHead(200,{'Content-Type':mime[extname(file)]||'application/octet-stream','Cache-Control':'no-cache'}).end(body);
|
||||
}catch{res.writeHead(404).end()}});
|
||||
await new Promise(r=>server.listen(5192,'127.0.0.1',r));
|
||||
const channel=process.env.REVIEW_CHANNEL||'chrome';
|
||||
const browser=await chromium.launch({channel,headless:true,args:['--use-angle=d3d11','--enable-gpu','--ignore-gpu-blocklist']});
|
||||
const report={channel,version:browser.version(),release:metadata.version,checks:[],errors:[]};
|
||||
try {
|
||||
const context=await browser.newContext({viewport:{width:1440,height:900}});
|
||||
await context.addInitScript(()=>{if(!localStorage.getItem('rhine-settings'))localStorage.setItem('rhine-settings',JSON.stringify({reduced:true,sound:false,music:false}))});
|
||||
const page=await context.newPage();page.on('pageerror',e=>report.errors.push(e.message));
|
||||
const ready=()=>page.waitForFunction(()=>window.rhine?.stats().ready&&document.documentElement.dataset.offlineReady==='true'&&navigator.serviceWorker.controller&&!document.querySelector('#loading'),null,{timeout:90000});
|
||||
const base='http://127.0.0.1:5192/';
|
||||
await page.goto(base);await ready();assert.equal(await page.locator('.settings-label').count(),0);
|
||||
await page.evaluate(()=>localStorage.setItem('rhine-saved','["X-001"]'));
|
||||
deployed=true;
|
||||
const cdp=await context.newCDPSession(page);await cdp.send('Network.clearBrowserCache');await cdp.detach();
|
||||
await page.reload();await ready();
|
||||
await page.waitForFunction(async()=>Boolean((await navigator.serviceWorker.getRegistration())?.waiting),null,{timeout:90000});
|
||||
assert.equal(await page.locator('.settings-label').count(),0);
|
||||
report.checks.push('clearing HTTP cache and reloading still serves the previous service-worker release');
|
||||
await page.goto(base+'update.html');await page.getByRole('button',{name:'更新并返回'}).click();
|
||||
await page.waitForURL(base);await ready();assert.equal(await page.locator('.settings-label').textContent(),'设置');
|
||||
assert.ok((await page.evaluate(()=>caches.keys())).some(k=>k.endsWith(metadata.version)));
|
||||
assert.equal(await page.evaluate(()=>localStorage.getItem('rhine-saved')),'["X-001"]');
|
||||
assert.equal(await page.evaluate(()=>JSON.parse(localStorage.getItem('rhine-settings')).reduced),true);
|
||||
report.checks.push('network recovery replaces the old page and preserves bookmarks and motion preference');
|
||||
broken=true;
|
||||
await page.goto(base+'update.html');await page.getByRole('button',{name:'更新并返回'}).click();
|
||||
await page.waitForFunction(()=>document.querySelector('#status').textContent.includes('更新未完成'));
|
||||
assert.equal(new URL(page.url()).pathname,'/update.html');assert.equal(await page.locator('#update').isEnabled(),true);
|
||||
report.checks.push('failed recovery download reports failure and retains the previous release');broken=false;
|
||||
await page.goto(base);await ready();
|
||||
await context.setOffline(true);await page.reload();await ready();assert.equal(await page.locator('.settings-label').textContent(),'设置');
|
||||
report.checks.push('recovered release works after offline reload');await context.close();
|
||||
const fresh=await browser.newContext();const freshPage=await fresh.newPage();
|
||||
await freshPage.goto(base+'update.html');await freshPage.getByRole('button',{name:'更新并返回'}).click();await freshPage.waitForURL(base,{timeout:90000});
|
||||
report.checks.push('recovery also works without a previous service worker');await fresh.close();
|
||||
assert.deepEqual(report.errors,[]);console.log(JSON.stringify(report,null,2));
|
||||
}finally{await mkdir('.tools/responsive',{recursive:true});await writeFile(`.tools/responsive/recovery-${channel}.json`,JSON.stringify(report,null,2));await browser.close();await new Promise(r=>server.close(r))}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Uses a disposable HTTP server to exercise real service-worker updates/failures.
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer } from 'node:http';
|
||||
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
||||
import { resolve, extname } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
const {chromium}=await import(process.env.PLAYWRIGHT_MODULE?pathToFileURL(resolve(process.env.PLAYWRIGHT_MODULE)).href:'playwright');
|
||||
const metadata=JSON.parse(await readFile('dist/pwa-build.json','utf8'));
|
||||
let revision=1,fail=false;
|
||||
const root=resolve('dist');
|
||||
const mime={'.html':'text/html','.js':'text/javascript','.css':'text/css','.json':'application/json','.webmanifest':'application/manifest+json','.svg':'image/svg+xml','.png':'image/png','.woff2':'font/woff2','.txt':'text/plain','.glb':'model/gltf-binary','.ogg':'audio/ogg'};
|
||||
const server=createServer(async(req,res)=>{try{
|
||||
const url=new URL(req.url,'http://localhost');const path=decodeURIComponent(url.pathname==='/'?'/index.html':url.pathname);const file=resolve(root,`.${path}`);
|
||||
if(!file.startsWith(root+ '/'.replace('/',process.platform==='win32'?'\\':'/')))throw Error();
|
||||
if(fail&&path==='/icons/icon-192.png'){res.writeHead(503).end();return}
|
||||
let body=await readFile(file);
|
||||
if(path==='/sw.js'&&revision>1)body=Buffer.from(body.toString().replace(metadata.version,`${metadata.version}-test-${revision}`));
|
||||
res.writeHead(200,{'Content-Type':mime[extname(file)]||'application/octet-stream','Cache-Control':'no-cache'}).end(body);
|
||||
}catch{res.writeHead(404).end()}});
|
||||
await new Promise(resolve=>server.listen(5191,'127.0.0.1',resolve));
|
||||
const browser=await chromium.launch({channel:'chrome',headless:true,args:['--use-angle=d3d11','--enable-gpu','--ignore-gpu-blocklist']});
|
||||
const context=await browser.newContext({viewport:{width:390,height:844},hasTouch:true,isMobile:true});
|
||||
await context.addInitScript(()=>{if(!localStorage.getItem('rhine-settings'))localStorage.setItem('rhine-settings',JSON.stringify({reduced:true,sound:false,music:false}))});
|
||||
const page=await context.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message));
|
||||
const ready=()=>page.waitForFunction(()=>window.rhine?.stats().ready&&document.documentElement.dataset.offlineReady==='true'&&navigator.serviceWorker.controller&&!document.querySelector('#loading'),null,{timeout:90000});
|
||||
const report={version:metadata.version,bytes:metadata.bytes,files:metadata.files.length,checks:[],errors};
|
||||
try{
|
||||
await page.goto('http://127.0.0.1:5191/?scene=archive');await ready();
|
||||
const manifest=await page.evaluate(async()=>await(await fetch(document.querySelector('link[rel="manifest"]').href)).json());
|
||||
assert.equal(manifest.display,'standalone');assert.equal(manifest.scope,'./');assert.equal(manifest.icons.length,3);
|
||||
await page.evaluate(async()=>{await caches.open('unrelated-app');localStorage.setItem('rhine-saved','["X-001"]')});
|
||||
report.checks.push('manifest, installation, atomic full-resource cache');
|
||||
await context.setOffline(true);await page.reload();await ready();
|
||||
await page.locator('.read-file').click();await page.waitForFunction(()=>window.rhine.stats().decryption.clarity===1);
|
||||
const exported=await page.locator('.export-button').evaluate(async a=>{const r=await fetch(a.href);return {ok:r.ok,text:await r.text()}});assert.ok(exported.ok&&exported.text.includes('X-001'));
|
||||
await page.locator('.viewer-open').click();await page.waitForFunction(()=>JSON.parse(document.querySelector('.model-viewer')?.dataset.stats||'{}').ready);
|
||||
await page.locator('[data-viewer="explode"]').click();await page.waitForFunction(()=>JSON.parse(document.querySelector('.model-viewer').dataset.stats).spread===1);
|
||||
await mkdir('.tools/responsive',{recursive:true});await page.screenshot({path:'.tools/responsive/pwa-offline.png'});
|
||||
assert.ok(await page.evaluate(async()=>{const r=await fetch('/audio/motif.ogg');return r.ok&&(await r.arrayBuffer()).byteLength>100000}));
|
||||
report.checks.push('offline reload, fonts, document export, model viewer, explosion and audio resource');
|
||||
await context.setOffline(false);revision=2;
|
||||
await page.evaluate(async()=>{const r=await navigator.serviceWorker.getRegistration();await r.update()});
|
||||
await page.waitForFunction(async()=>Boolean((await navigator.serviceWorker.getRegistration())?.waiting),null,{timeout:90000});
|
||||
assert.equal(await page.locator('#pwa-update-notice').isVisible(),false,'Viewer must isolate the outside update action');
|
||||
await page.locator('[data-viewer="close"]').click();
|
||||
await page.waitForSelector('#pwa-update-notice:not([hidden])');
|
||||
assert.equal(await page.evaluate(()=>document.querySelector('#stage').dataset.mode),'detail','Waiting update must not interrupt the page');
|
||||
await Promise.all([page.waitForNavigation(),page.locator('#pwa-update-notice [data-pwa-action="update"]').click()]);await ready();
|
||||
assert.equal(await page.evaluate(()=>localStorage.getItem('rhine-saved')),'["X-001"]');
|
||||
const keys=await page.evaluate(()=>caches.keys());assert.ok(keys.includes('unrelated-app'));assert.equal(keys.filter(k=>k.startsWith('rhine-lab:')).length,1);assert.ok(keys.some(k=>k.endsWith('-test-2')));
|
||||
report.checks.push('visible update action without opening settings, explicit restart, old-cache cleanup and preserved preferences/bookmarks');
|
||||
revision=3;fail=true;
|
||||
await page.evaluate(async()=>{const r=await navigator.serviceWorker.getRegistration();await r.update()});
|
||||
await page.waitForFunction(async()=>{const r=await navigator.serviceWorker.getRegistration();return !r.installing&&!r.waiting},null,{timeout:90000});
|
||||
await context.setOffline(true);await page.reload();await ready();
|
||||
assert.ok((await page.evaluate(()=>caches.keys())).some(k=>k.endsWith('-test-2')));
|
||||
assert.ok(!(await page.evaluate(()=>caches.keys())).some(k=>k.endsWith('-test-3')));
|
||||
report.checks.push('failed update leaves the previous complete offline release usable');
|
||||
assert.deepEqual(errors,[]);console.log(JSON.stringify(report,null,2));
|
||||
}finally{await writeFile('.tools/responsive/pwa-report.json',JSON.stringify(report,null,2));await browser.close();await new Promise(r=>server.close(r))}
|
||||
@@ -0,0 +1,78 @@
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
normalizeQuality,
|
||||
qualityPresets,
|
||||
matchingPreset,
|
||||
renderDimensions,
|
||||
} from "../src/render-quality.ts";
|
||||
|
||||
assert.deepEqual(normalizeQuality(null), qualityPresets.original);
|
||||
const legacy = normalizeQuality(undefined, false);
|
||||
assert.equal(legacy.pixelRatio, 1);
|
||||
assert.equal(legacy.aoSamples, 0);
|
||||
assert.equal(legacy.depthOfField, 0);
|
||||
assert.equal(legacy.shadows, 2048, "Migration preserves old low-mode shadows");
|
||||
assert.equal(legacy.transmission, 1, "Migration preserves old low-mode glass");
|
||||
for (const bad of [
|
||||
null,
|
||||
false,
|
||||
"ultra",
|
||||
[],
|
||||
{
|
||||
scale: NaN,
|
||||
pixelRatio: 99,
|
||||
antialias: "injected",
|
||||
shadows: -1,
|
||||
aoSamples: Infinity,
|
||||
},
|
||||
]) {
|
||||
assert.deepEqual(normalizeQuality(bad), qualityPresets.original);
|
||||
}
|
||||
assert.equal(normalizeQuality({ scale: 99999 }).scale, 200);
|
||||
assert.equal(normalizeQuality({ scale: -1 }).scale, 50);
|
||||
assert.equal(normalizeQuality({ depthOfField: 99999 }).depthOfField, 150);
|
||||
for (const [name, quality] of Object.entries(qualityPresets)) {
|
||||
assert.equal(matchingPreset(normalizeQuality(quality)), name);
|
||||
const reloaded = normalizeQuality(JSON.parse(JSON.stringify(quality)));
|
||||
assert.deepEqual(reloaded, quality);
|
||||
}
|
||||
assert.equal(matchingPreset({ ...qualityPresets.ultra, scale: 145 }), "custom");
|
||||
for (const width of [640, 1920, 3840, 7680]) {
|
||||
for (const dpr of [1, 1.5, 2, 3]) {
|
||||
for (const max of [2048, 4096, 16384]) {
|
||||
const dimensions = renderDimensions(
|
||||
normalizeQuality({ scale: 200, pixelRatio: 3 }),
|
||||
1920,
|
||||
1080,
|
||||
width / 1920,
|
||||
dpr,
|
||||
max,
|
||||
);
|
||||
assert.ok(dimensions.width * dimensions.height <= 8_294_400);
|
||||
assert.ok(dimensions.width <= max && dimensions.height <= max);
|
||||
assert.ok(dimensions.ratio > 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
const native = renderDimensions(
|
||||
qualityPresets.original,
|
||||
1920,
|
||||
1080,
|
||||
1,
|
||||
1,
|
||||
16384,
|
||||
);
|
||||
const ultra = renderDimensions(qualityPresets.ultra, 1920, 1080, 1, 1, 16384);
|
||||
assert.equal(native.width, 1920);
|
||||
assert.equal(ultra.width, 2880);
|
||||
assert.equal(
|
||||
(ultra.width * ultra.height) / (native.width * native.height),
|
||||
2.25,
|
||||
);
|
||||
assert.equal(
|
||||
renderDimensions(qualityPresets.ultra, 1920, 1080, 2, 2, 16384).limited,
|
||||
true,
|
||||
);
|
||||
console.log(
|
||||
"Quality checks passed: migration, invalid storage, presets, supersampling and 48 device-limit combinations.",
|
||||
);
|
||||
@@ -0,0 +1,111 @@
|
||||
// Browser regression against the actual WebGL application. Run with local Vite.
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
const { chromium, webkit } = await import(process.env.PLAYWRIGHT_MODULE ? pathToFileURL(resolve(process.env.PLAYWRIGHT_MODULE)).href : "playwright");
|
||||
const base=process.env.REVIEW_URL || "http://127.0.0.1:5204";
|
||||
const output=resolve(".tools/responsive");await mkdir(output,{recursive:true});
|
||||
const engine=process.env.REVIEW_ENGINE || "chromium";
|
||||
const browser=engine==='webkit'?await webkit.launch({headless:true}):await chromium.launch({channel:'chrome',headless:true,args:['--use-angle=d3d11','--enable-gpu','--ignore-gpu-blocklist']});
|
||||
const cases=engine==='webkit' ? [['safari-portrait',390,844,true],['safari-landscape',844,390,true]] :
|
||||
[['desktop',1920,1080,false],['laptop',1440,900,false],['wide',2560,1080,false],['ultrawide',3840,1080,false],['tablet',1280,1024,false],['landscape',844,390,true],['portrait',390,844,true],['small',320,568,true],['short-landscape',568,320,true]];
|
||||
const report=[];
|
||||
const stats=page=>page.evaluate(()=>window.rhine.stats());
|
||||
async function bounds(page,selectors){return page.evaluate(selectors=>Object.fromEntries(selectors.map(s=>{const el=document.querySelector(s),r=el.getBoundingClientRect();return [s,{x:r.x,y:r.y,width:r.width,height:r.height,right:r.right,bottom:r.bottom}]})),selectors)}
|
||||
async function inside(page,selectors,w,h){const rects=await bounds(page,selectors);for(const [s,r] of Object.entries(rects))assert.ok(r.x>=-1&&r.y>=-1&&r.right<=w+1&&r.bottom<=h+1,`${s} outside ${w}x${h}: ${JSON.stringify(r)}`);return rects}
|
||||
async function touch(page,points){
|
||||
if(engine==='webkit') {
|
||||
// Real WebKit pointer handlers; real multi-touch requires an iPhone.
|
||||
await page.evaluate(points=>{const el=document.querySelector('#three-scene canvas');el.setPointerCapture=()=>{};for(const [i,[x,y]] of points.entries())el.dispatchEvent(new PointerEvent(i===0?'pointerdown':i===points.length-1?'pointerup':'pointermove',{pointerId:77,pointerType:'touch',isPrimary:true,clientX:x,clientY:y,bubbles:true}));},points);return;
|
||||
}
|
||||
const session=await page.context().newCDPSession(page);
|
||||
await session.send('Input.dispatchTouchEvent',{type:'touchStart',touchPoints:[{x:points[0][0],y:points[0][1],id:1}]});
|
||||
for(const [x,y] of points.slice(1)){await session.send('Input.dispatchTouchEvent',{type:'touchMove',touchPoints:[{x,y,id:1}]});await page.waitForTimeout(16)}
|
||||
// These are precise single-cell gestures; free flicks have their own momentum checks.
|
||||
await page.waitForTimeout(160);
|
||||
await session.send('Input.dispatchTouchEvent',{type:'touchEnd',touchPoints:[]});await session.detach();
|
||||
}
|
||||
try{
|
||||
for(const [name,width,height,mobile] of cases.filter(([name])=>!process.env.REVIEW_CASES||process.env.REVIEW_CASES.split(',').includes(name))){
|
||||
const context=await browser.newContext({viewport:{width,height},hasTouch:mobile,isMobile:mobile,deviceScaleFactor:mobile?2:1});
|
||||
const page=await context.newPage(),errors=[];page.on('pageerror',e=>errors.push(e.message));
|
||||
await page.goto(`${base}/?scene=archive`);
|
||||
await page.waitForFunction(()=>window.rhine?.stats().ready&&!document.querySelector('#loading'),null,{timeout:60000});
|
||||
await page.waitForFunction(()=>window.rhine.stats().extraction>=.395,null,{timeout:60000});await page.waitForTimeout(300);
|
||||
const entry={name,viewport:{width,height},errors};report.push(entry);
|
||||
entry.archive=await inside(page,['.brand','.system-nav','.read-file','.archive-navigation','.column-navigation','.archive-counter'],width,height);
|
||||
await page.screenshot({path:resolve(output,`${name}-archive-final.png`)});
|
||||
const before=await stats(page);
|
||||
if(mobile){
|
||||
const y=Math.round(height*(width>height?.4:.25)),x=Math.round(width*.4);
|
||||
let vector=(await stats(page)).dragProjection.lane;
|
||||
await touch(page,[[x,y],[x+vector.x*.4,y+vector.y*.4],[x+vector.x*.8,y+vector.y*.8]]);
|
||||
await page.waitForFunction(()=>!rhine.stats().archiveMomentum);
|
||||
assert.equal((await stats(page)).selectedCell.lane,before.selectedCell.lane+1,'Projected column travel advances one column');
|
||||
const row=(await stats(page)).selectedCell.row;
|
||||
vector=(await stats(page)).dragProjection.row;
|
||||
await touch(page,[[x,y],[x+vector.x*.4,y+vector.y*.4],[x+vector.x*.8,y+vector.y*.8]]);
|
||||
await page.waitForFunction(()=>!rhine.stats().archiveMomentum);
|
||||
assert.equal((await stats(page)).selectedCell.row,row+1,'Projected depth travel advances one file');
|
||||
}
|
||||
// Eight steps traverse the seam without changing the remembered content.
|
||||
const loop=await stats(page);
|
||||
for(let i=0;i<8;i++){await page.locator('[data-action="next"]').click();await page.waitForTimeout(65)}
|
||||
assert.equal((await stats(page)).selected,loop.selected);
|
||||
assert.equal((await stats(page)).selectedCell.row,loop.selectedCell.row+8);
|
||||
await page.waitForTimeout(2000);
|
||||
await page.locator('.read-file').click();
|
||||
await page.waitForFunction(()=>window.rhine.stats().decryption.clarity===1,null,{timeout:60000});await page.waitForTimeout(1400);
|
||||
entry.detail=await inside(page,['.back-button','.viewer-open','.detail-content'],width,height);
|
||||
entry.detailStats=await stats(page);
|
||||
await page.screenshot({path:resolve(output,`${name}-detail-final.png`)});
|
||||
assert.equal(entry.detailStats.extraction,4.05);
|
||||
assert.ok(entry.detailStats.canInspect);
|
||||
// The document can reach actions on short displays; bookmarking preserves scroll.
|
||||
await page.locator('[data-action="bookmark"]').scrollIntoViewIfNeeded();
|
||||
const scroll=await page.locator('.detail-content').evaluate(el=>el.scrollTop);
|
||||
await page.locator('[data-action="bookmark"]').click();
|
||||
assert.equal(await page.locator('.detail-content').evaluate(el=>el.scrollTop),scroll);
|
||||
await page.locator('.viewer-open').click();
|
||||
await page.waitForFunction(()=>JSON.parse(document.querySelector('.model-viewer')?.dataset.stats||'{}').ready,null,{timeout:60000});
|
||||
await page.waitForTimeout(550);
|
||||
await page.locator('[data-viewer="explode"]').click();await page.waitForFunction(()=>JSON.parse(document.querySelector('.model-viewer').dataset.stats).spread>.999);
|
||||
await inside(page,['.viewer-back','.viewer-actions','.viewer-reset','.viewer-surface'],width,height);
|
||||
await page.screenshot({path:resolve(output,`${name}-viewer-final.png`)});
|
||||
const viewerBefore=await page.locator('.model-viewer').evaluate(el=>JSON.parse(el.dataset.stats));
|
||||
if(mobile&&engine==='chromium') {
|
||||
const host=await page.locator('.viewer-canvas').boundingBox();const x=host.x+host.width*.5,y=host.y+host.height*.5;
|
||||
const session=await context.newCDPSession(page);
|
||||
const points=(dx,dy=0)=>[{x:x-dx,y:y+dy,id:1},{x:x+dx,y:y+dy,id:2}];
|
||||
await session.send('Input.dispatchTouchEvent',{type:'touchStart',touchPoints:points(35)});
|
||||
for(const dx of [42,50,60]){await session.send('Input.dispatchTouchEvent',{type:'touchMove',touchPoints:points(dx,15)});await page.waitForTimeout(30)}
|
||||
await session.send('Input.dispatchTouchEvent',{type:'touchEnd',touchPoints:[]});await page.waitForTimeout(500);
|
||||
const after=await page.locator('.model-viewer').evaluate(el=>JSON.parse(el.dataset.stats));
|
||||
assert.ok(after.requestedDistance<viewerBefore.requestedDistance,'Pinch out zooms in');
|
||||
assert.ok(Math.hypot(...after.requestedTarget)>0.01,'Two fingers pan the view');
|
||||
await session.detach();
|
||||
// Orientation changes keep the requested pose, selection, and exploded state.
|
||||
await page.setViewportSize({width:height,height:width});await page.waitForTimeout(400);
|
||||
const rotated=await page.locator('.model-viewer').evaluate(el=>JSON.parse(el.dataset.stats));
|
||||
assert.equal(rotated.target,1);assert.ok(Math.abs(rotated.requestedDistance-after.requestedDistance)<1e-5);
|
||||
assert.equal((await stats(page)).selected,entry.detailStats.selected);
|
||||
await page.setViewportSize({width,height});await page.waitForTimeout(350);
|
||||
}
|
||||
await page.locator('[data-viewer="assemble"]').click();await page.locator('[data-viewer="close"]').click();
|
||||
await page.waitForFunction(()=>document.querySelector('.model-viewer').hidden);
|
||||
assert.equal(await page.evaluate(()=>document.activeElement?.className),'viewer-open');
|
||||
await page.locator('[data-action="back"]').click();await page.waitForTimeout(350);
|
||||
for(const action of ['search','saved','settings']){
|
||||
await page.locator(`[data-action="${action}"]`).click();await page.waitForTimeout(350);
|
||||
await inside(page,['.terminal-modal','[data-action="close-modal"]'],width,height);
|
||||
if(action==='search'){
|
||||
await page.locator('#archive-search').fill('X-001');assert.equal(await page.locator('.result-row').count(),1);
|
||||
assert.ok(await page.locator('#archive-search').evaluate(el=>parseFloat(getComputedStyle(el).fontSize)>=16));
|
||||
}
|
||||
if(action==='settings')await page.screenshot({path:resolve(output,`${name}-settings-final.png`)});
|
||||
await page.locator('[data-action="close-modal"]').click();await page.waitForFunction(()=>!document.querySelector('.modal-backdrop'));
|
||||
}
|
||||
assert.deepEqual(errors,[]);console.log(`${engine} ${name}: passed`);await context.close();
|
||||
}
|
||||
}finally{await writeFile(resolve(output,`regression-${engine}.json`),JSON.stringify(report,null,2));await browser.close()}
|
||||
@@ -0,0 +1,33 @@
|
||||
import {createRequire} from 'node:module';
|
||||
import {mkdir,writeFile} from 'node:fs/promises';
|
||||
import assert from 'node:assert/strict';
|
||||
const require=createRequire(import.meta.url),{chromium}=require(process.env.PLAYWRIGHT_MODULE||'playwright');
|
||||
const browser=await chromium.launch({channel:'msedge',headless:true});
|
||||
try {
|
||||
const page=await browser.newPage({viewport:{width:1600,height:900}}),errors=[];
|
||||
page.on('pageerror',e=>errors.push(String(e)));page.on('console',m=>{if(m.type()==='error'&&/THREE|shader|WebGL/.test(m.text()))errors.push(m.text())});
|
||||
await page.goto('http://127.0.0.1:5176/?scene=archive');await page.waitForFunction(()=>window.rhine?.stats().ready);
|
||||
const apply=async(values)=>{await page.evaluate(values=>window.wallpaperPropertyListener.applyUserProperties(Object.fromEntries(Object.entries(values).map(([k,value])=>[k,{value}]))),values);await page.waitForTimeout(300)};
|
||||
const stats=()=>page.evaluate(()=>window.rhine.stats());
|
||||
await apply({desktopmode:'workbench',boot:false,superperformance:true,screenfinish:false,hudparallax:false,uifrost:false,sound:false,music:false,reduced:false,colortheme:'dark',selectedindexaccent:true,selectionstyle:'music-flat',audioreactive:true});
|
||||
await page.waitForFunction(()=>window.rhine.stats().selectedIndexDim<.02&&window.rhine.stats().extraction>.38);
|
||||
const before=await stats();
|
||||
// Simulate the host capturing the wallpaper's own sounds during a real drag.
|
||||
await page.evaluate(()=>{window.localNoise=setInterval(()=>{const t=performance.now()/1000;window.dispatchEvent(new CustomEvent('rhine-local-sound',{detail:{until:t+.5}}));window.rhineWallpaperSpectrum={samples:Array(128).fill(.2),time:t}},35)});
|
||||
await page.mouse.move(860,480);await page.mouse.down();await page.mouse.move(1120,570,{steps:18});await page.mouse.up();
|
||||
await page.waitForTimeout(1200);const dragged=await stats();
|
||||
await page.evaluate(()=>{clearInterval(window.localNoise);window.rhineWallpaperSpectrum={samples:Array(128).fill(0),time:performance.now()/1000}});
|
||||
assert.equal(dragged.flatten,0);assert.equal(dragged.spectrumActivity,0);
|
||||
await page.waitForFunction(()=>window.rhine.stats().selectedIndexDim<.02&&window.rhine.stats().extraction>.38&&!window.rhine.stats().archiveMomentum);
|
||||
await page.evaluate(()=>{const n=Number(window.rhine.stats().selected.slice(2))-1;window.rhine.select((n+1)%40)});
|
||||
const frames=[];for(let i=0;i<14;i++){await page.waitForTimeout(120);frames.push(await stats())}
|
||||
assert.ok(frames.some(f=>f.selectedIndexDim>.03&&f.selectedIndexDim<.97),'New selected accent fades in');
|
||||
assert.ok(frames.some(f=>f.returningIndexDims.some(x=>x.dim>.03&&x.dim<.97)),'Outgoing accent fades out');
|
||||
await apply({selectionstyle:'flat'});await page.waitForFunction(()=>window.rhine.stats().flatten>.995);
|
||||
const flat=await stats();assert.ok(flat.selectedIndexDim>.99);
|
||||
await apply({selectedindexaccent:false});assert.ok((await stats()).selectedIndexDim>.99,'Flat disables accent even when all-label style is selected');
|
||||
await mkdir('verification/selection-state',{recursive:true});await page.screenshot({path:'verification/selection-state/flat.png'});
|
||||
await apply({selectedindexaccent:true,selectionstyle:'music-flat'});await page.waitForFunction(()=>window.rhine.stats().flatten<.01&&window.rhine.stats().selectedIndexDim<.02&&window.rhine.stats().extraction>.38);await page.screenshot({path:'verification/selection-state/silent-selected.png'});
|
||||
assert.deepEqual(errors,[]);await writeFile('verification/selection-state/results.json',JSON.stringify({before,dragged,frames:frames.map(f=>({dim:f.selectedIndexDim,returning:f.returningIndexDims,lift:f.extraction})),flat,errors},null,2));
|
||||
console.log('Silent drag retains unflattened array; both label directions interpolate; flat mode has no accent; restore passed.');
|
||||
} finally {await browser.close()}
|
||||
@@ -0,0 +1,85 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import * as T from "three";
|
||||
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
|
||||
import { glassRevealAtHeight as reveal } from "../src/glass-reveal.ts";
|
||||
import { decryptionFrame } from "../src/decryption.ts";
|
||||
async function load(path) {
|
||||
const b = await readFile(new URL(path, import.meta.url));
|
||||
const s = (
|
||||
await new GLTFLoader().parseAsync(
|
||||
b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength),
|
||||
"",
|
||||
)
|
||||
).scene;
|
||||
s.updateMatrixWorld(true);
|
||||
return s;
|
||||
}
|
||||
// Subsequent ring changes are covered by check-internal-optics.mjs.
|
||||
const after = await load("../public/assets/archive-assembly.glb");
|
||||
let patch,
|
||||
metal,
|
||||
engravingDepth = -Infinity;
|
||||
after.traverse((m) => {
|
||||
if (!m.isMesh) return;
|
||||
const name = m.material.name.replace(/\.\d+$/, "");
|
||||
assert.notEqual(name, "Amber_Lightguide");
|
||||
const box = new T.Box3().setFromObject(m);
|
||||
if (name === "Index_Inlay") patch = box;
|
||||
if (name === "Titanium_Fasteners") metal = m;
|
||||
if (name.startsWith("Case_") && m.userData.assemblyPart === "cover")
|
||||
engravingDepth = Math.max(engravingDepth, box.max.z);
|
||||
});
|
||||
assert.ok(patch && metal);
|
||||
assert.ok(Math.abs(patch.max.y - 3.7) < 1e-5, "Patch flush with top");
|
||||
assert.ok(Math.abs(patch.max.z - 0.206) < 1e-5, "Patch flush with front");
|
||||
assert.ok(Math.abs(patch.max.x - patch.min.x - 0.25) < 1e-5);
|
||||
assert.ok(
|
||||
engravingDepth < 0.174,
|
||||
"Every frame/boss engraving lies behind front cover",
|
||||
);
|
||||
const positions = metal.geometry.attributes.position;
|
||||
let tr = 0,
|
||||
bl = 0;
|
||||
for (let i = 0; i < positions.count; i++) {
|
||||
const v = new T.Vector3()
|
||||
.fromBufferAttribute(positions, i)
|
||||
.applyMatrix4(metal.matrixWorld);
|
||||
if (v.x > 2.25 && v.y > 3.4) tr++;
|
||||
else if (v.x < -2.2 && v.y < 0.23) bl++;
|
||||
else assert.fail("Extra screw outside top-right / bottom-left");
|
||||
}
|
||||
assert.ok(tr > 0 && bl > 0);
|
||||
for (let h = 0; h <= 1; h += 0.01) {
|
||||
assert.equal(reveal(0, h), 0);
|
||||
assert.equal(reveal(1, h), 1);
|
||||
let last = 0;
|
||||
for (let p = 0; p <= 1; p += 0.01) {
|
||||
const next = reveal(p, h);
|
||||
assert.ok(next >= last);
|
||||
last = next;
|
||||
}
|
||||
}
|
||||
assert.equal(reveal(0.5, 0.9), 1);
|
||||
assert.equal(reveal(0.5, 0.1), 0);
|
||||
let last = 0;
|
||||
for (let t = 38.84; t <= 39.56; t += 0.001) {
|
||||
const p = decryptionFrame(t).clarity;
|
||||
assert.ok(p >= last);
|
||||
last = p;
|
||||
}
|
||||
assert.equal(decryptionFrame(38.84).clarity, 0);
|
||||
assert.equal(decryptionFrame(39.56).clarity, 1);
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
passed: true,
|
||||
screwRegions: 2,
|
||||
patchSize: patch.getSize(new T.Vector3()).toArray(),
|
||||
engravingDepth,
|
||||
reveal: "fully frosted → top to bottom → fully clear",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,12 @@
|
||||
import {createServer} from 'node:http';
|
||||
import {spawn} from 'node:child_process';
|
||||
import {readFile,writeFile,mkdir,cp} from 'node:fs/promises';
|
||||
import {resolve} from 'node:path';
|
||||
import assert from 'node:assert/strict';
|
||||
const dir=resolve('verification/startup-2d/host');await mkdir(dir,{recursive:true});await cp('release/wallpaper',dir,{recursive:true});
|
||||
const project=JSON.parse(await readFile(dir+'/project.json','utf8'));delete project.workshopid;delete project.workshopurl;project.general.properties.load3donstartup.value=false;project.general.properties.boot.value=true;await writeFile(dir+'/project.json',JSON.stringify(project));
|
||||
const html=await readFile(dir+'/index.html','utf8');
|
||||
await writeFile(dir+'/index.html',html.replace('</head>',`<script>let checked=false;const probe=setInterval(()=>{if(!window.rhine?.stats().ready)return;if(!checked){checked=true;rhine.seek(21.8);return}if(rhine.stats().mode!=='archive')return;clearInterval(probe);fetch('http://127.0.0.1:5185/',{method:'POST',body:JSON.stringify({state:rhine.stats().threeState,mode:rhine.stats().mode,property:rhine.stats().wallpaper.properties.load3donstartup.value,canvas:document.querySelectorAll('canvas').length,models:performance.getEntriesByType('resource').filter(x=>x.name.includes('.glb')).length})})},100)</script></head>`));
|
||||
let finish;const result=new Promise(r=>finish=r);const server=createServer((req,res)=>{let body='';req.on('data',b=>body+=b);req.on('end',()=>{res.setHeader('Access-Control-Allow-Origin','*');res.end('ok');try{finish(JSON.parse(body))}catch{}})});await new Promise(r=>server.listen(5185,'127.0.0.1',r));
|
||||
const exe='D:/Game/Steam/steamapps/common/wallpaper_engine/wallpaper64.exe',location='Rhine Lab startup diagnostic';const run=args=>new Promise((ok,no)=>{const child=spawn(exe,args,{windowsHide:true,stdio:'ignore'});child.once('error',no);child.once('exit',ok)});let timeout;
|
||||
try{await run(['-control','openWallpaper','-file',dir+'/project.json','-playInWindow',location,'-width','640','-height','360','-x','-30000','-y','-30000']);const data=await Promise.race([result,new Promise((_,no)=>timeout=setTimeout(()=>no(Error('Host timeout')),30000))]);console.log(data);await writeFile('verification/startup-2d/host-results.json',JSON.stringify(data,null,2));assert.deepEqual(data,{state:'off',mode:'archive',property:false,canvas:0,models:0})}finally{clearTimeout(timeout);server.close();await run(['-control','closeWallpaper','-location',location])}
|
||||
@@ -0,0 +1,23 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {createRequire} from 'node:module';
|
||||
import {createServer} from 'node:http';
|
||||
import {readFile,mkdir,writeFile} from 'node:fs/promises';
|
||||
import {resolve,extname} from 'node:path';
|
||||
const {chromium}=createRequire(import.meta.url)(process.env.PLAYWRIGHT_MODULE||'playwright');
|
||||
let enabled=false;
|
||||
const server=createServer(async(req,res)=>{try{let path=decodeURIComponent(new URL(req.url,'http://localhost').pathname);if(path==='/')path='/index.html';let data=await readFile(resolve('release/wallpaper'+path));if(path.endsWith('.html'))data=Buffer.from(data.toString().replace('</head>',`<script>setTimeout(()=>wallpaperPropertyListener.applyUserProperties({load3donstartup:{value:${enabled}},desktopmode:{value:'workbench'},sound:{value:false},music:{value:false}}),1800)</script></head>`));res.setHeader('Content-Type',({'.js':'text/javascript','.css':'text/css','.html':'text/html','.json':'application/json','.woff2':'font/woff2'})[extname(path)]||'application/octet-stream');res.end(data)}catch{res.statusCode=404;res.end()}});
|
||||
await new Promise(r=>server.listen(5184,'127.0.0.1',r));
|
||||
const browser=await chromium.launch({channel:'msedge',headless:true});
|
||||
try{const page=await browser.newPage({viewport:{width:1280,height:720}}),models=[],errors=[];
|
||||
await page.addInitScript(()=>{window.wallpaperRegisterAudioListener=()=>{}});
|
||||
page.on('request',r=>{if(r.url().includes('.glb'))models.push(r.url())});page.on('pageerror',e=>errors.push(e.message));
|
||||
await page.goto('http://127.0.0.1:5184/');await page.waitForTimeout(600);assert.equal(await page.locator('canvas').count(),0);assert.equal(models.length,0);await page.waitForFunction(()=>window.rhine?.stats().startup==='started');
|
||||
assert.equal(await page.locator('canvas').count(),0);assert.equal(models.length,0);assert.equal(await page.evaluate(()=>rhine.stats().mode),'boot');
|
||||
await page.evaluate(()=>rhine.seek(21.8));await page.waitForFunction(()=>rhine.stats().mode==='archive');assert.equal(await page.locator('canvas').count(),0);
|
||||
await page.locator('[data-action="toggle-three"]').click();await page.waitForFunction(()=>rhine.stats().threeState==='on',{},{timeout:60000});assert.ok(models.length>0);assert.equal(await page.locator('#three-scene canvas').count(),1);
|
||||
await page.evaluate(()=>wallpaperPropertyListener.applyUserProperties({load3donstartup:{value:false}}));assert.equal(await page.evaluate(()=>rhine.stats().threeState),'on');
|
||||
await mkdir('verification/startup-2d',{recursive:true});await page.screenshot({path:'verification/startup-2d/manual-on.png'});
|
||||
enabled=true;await page.reload();await page.waitForFunction(()=>rhine.stats().startup==='started');assert.equal(await page.locator('#three-scene canvas').count(),1);assert.equal(await page.evaluate(()=>rhine.stats().threeState),'on');assert.deepEqual(errors,[]);
|
||||
await writeFile('verification/startup-2d/results.json',JSON.stringify({delayedHostCallbackMs:1800,noInitialCanvas:true,noInitialModelRequests:true,bootTo2d:true,manualLoad:true,startupOnly:true,default3d:true,errors},null,2));console.log('2D startup, no canvas/model requests, boot completion, manual load, startup-only property and default 3D passed.');
|
||||
}finally{await browser.close();server.close()}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
// Real browser checks for entry audio, first-load fonts and failure recovery.
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
const { chromium, webkit } = await import(process.env.PLAYWRIGHT_MODULE ? pathToFileURL(resolve(process.env.PLAYWRIGHT_MODULE)).href : 'playwright');
|
||||
const base = process.env.REVIEW_URL || 'http://127.0.0.1:5190/';
|
||||
const engine = process.env.REVIEW_ENGINE || 'chromium';
|
||||
const browser = engine === 'webkit' ? await webkit.launch({headless:true}) : await chromium.launch({channel:process.env.REVIEW_CHANNEL || 'chrome',headless:true,args:['--use-angle=d3d11','--enable-gpu','--ignore-gpu-blocklist']});
|
||||
const report = {engine,version:browser.version(),checks:[],errors:[]};
|
||||
const output = resolve('.tools/issues');await mkdir(output,{recursive:true});
|
||||
const waitEntry = page => page.waitForFunction(()=>window.rhine?.stats().startup==='waiting',null,{timeout:60000});
|
||||
const waitStart = page => page.waitForFunction(()=>window.rhine?.stats().startup==='started'&&!document.querySelector('#loading'),null,{timeout:60000});
|
||||
async function fresh(options={},prefs) {
|
||||
const context=await browser.newContext({viewport:{width:1440,height:900},serviceWorkers:'block',reducedMotion:'no-preference',...options});
|
||||
if(prefs)await context.addInitScript(prefs=>localStorage.setItem('rhine-settings',JSON.stringify(prefs)),prefs);
|
||||
const page=await context.newPage();page.on('pageerror',e=>report.errors.push(e.message));return {context,page};
|
||||
}
|
||||
try {
|
||||
if(engine==='webkit') {
|
||||
// Windows WebKit lacks the required audio decoder. Exercise the entry
|
||||
// viewport and its real error path; never label this an iPhone audio test.
|
||||
const {context,page}=await fresh({viewport:{width:390,height:844},hasTouch:true,isMobile:true});
|
||||
await page.goto(base);await waitEntry(page);
|
||||
await page.screenshot({path:resolve(output,'entry-webkit.png')});
|
||||
await page.locator('.entry-start').tap();
|
||||
await page.waitForFunction(()=>['started','error'].includes(window.rhine.stats().startup),null,{timeout:25000});
|
||||
const state=await page.evaluate(()=>window.rhine.stats().startup);
|
||||
if(state==='error')await page.locator('.entry-silent').tap();
|
||||
await waitStart(page);report.checks.push({name:'WebKit portrait entry and decoder fallback',audioResult:state});await context.close();
|
||||
} else {
|
||||
for(const [name,options,input,prefs] of [
|
||||
['desktop',{},'click'],
|
||||
['portrait',{viewport:{width:390,height:844},hasTouch:true,isMobile:true},'tap'],
|
||||
['keyboard',{},'keyboard'],
|
||||
['reduced',{reducedMotion:'reduce'},'click'],
|
||||
['sound-only',{},'click',{sound:true,music:false}],
|
||||
['music-only',{},'click',{sound:false,music:true}],
|
||||
]) {
|
||||
const {context,page}=await fresh(options,prefs);
|
||||
await page.goto(base);await waitEntry(page);
|
||||
const before=await page.evaluate(()=>window.rhine.stats());
|
||||
assert.equal(before.audio.state,'locked');assert.equal(before.audio.tracks,0);
|
||||
assert.equal(await page.locator('#stage').evaluate(el=>el.inert),true);
|
||||
await page.waitForTimeout(700);
|
||||
assert.equal(await page.evaluate(()=>window.rhine.stats().bootTime),6.76);
|
||||
assert.equal(await page.evaluate(()=>document.documentElement.dataset.offlineReady),undefined);
|
||||
const fonts=await page.evaluate(()=>performance.getEntriesByType('resource').filter(e=>e.name.endsWith('.woff2')).map(e=>({url:new URL(e.name).pathname,bytes:e.decodedBodySize})));
|
||||
assert.ok(fonts.length>0&&fonts.every(f=>f.url.includes('/fonts/misans-webfont-4.3.1/')));
|
||||
assert.ok(fonts.reduce((n,f)=>n+f.bytes,0)<2*1024*1024,'Entry must not load entire font families');
|
||||
const rect=await page.locator('.entry-start').boundingBox(),vp=page.viewportSize();
|
||||
assert.ok(rect.x>=0&&rect.y>=0&&rect.x+rect.width<=vp.width&&rect.y+rect.height<=vp.height);
|
||||
if(name==='desktop'||name==='portrait')await page.screenshot({path:resolve(output,`entry-${name}.png`)});
|
||||
if(input==='keyboard')await page.keyboard.press('Enter');
|
||||
else if(input==='tap')await page.locator('.entry-start').tap();
|
||||
else await page.locator('.entry-start').click();
|
||||
await waitStart(page);
|
||||
const after=await page.evaluate(()=>window.rhine.stats());
|
||||
assert.equal(after.mode,name==='reduced'?'archive':'boot','Entry activation must not leak into skip/open');
|
||||
assert.equal(after.audio.state,'running');assert.equal(after.audio.loaded,name!=='sound-only');assert.equal(after.audio.tracks,name==='sound-only'?0:3);
|
||||
if(name!=='reduced') {
|
||||
assert.ok(after.bootTime<9,'Waiting time must not advance the animation');
|
||||
if(name!=='music-only')await page.waitForFunction(()=>window.rhine.stats().audio.playedKeys>0,null,{timeout:10000});
|
||||
}
|
||||
report.checks.push({name,entryFontBytes:fonts.reduce((n,f)=>n+f.bytes,0),fontRequests:fonts.length,audio:after.audio.state,tracks:after.audio.tracks});
|
||||
await context.close();
|
||||
}
|
||||
{
|
||||
const {context,page}=await fresh({}, {sound:false,music:false,reduced:true});
|
||||
await page.goto(base);await waitStart(page);assert.equal(await page.locator('.entry-start').count(),0);
|
||||
assert.equal(await page.evaluate(()=>window.rhine.stats().audio.state),'locked');
|
||||
report.checks.push({name:'Existing silent preference enters without opening audio'});await context.close();
|
||||
}
|
||||
{
|
||||
const {context,page}=await fresh();let fail=true;
|
||||
await page.route('**/audio/*.ogg',route=>fail?route.fulfill({status:503,body:'Unavailable'}):route.continue());
|
||||
await page.goto(base);await waitEntry(page);await page.locator('.entry-start').click();
|
||||
await page.waitForFunction(()=>window.rhine.stats().startup==='error');
|
||||
assert.equal(await page.evaluate(()=>window.rhine.stats().bootTime),6.76);
|
||||
assert.equal(await page.evaluate(()=>window.rhine.stats().audio.tracks),0);
|
||||
fail=false;await page.locator('.entry-start').click();await waitStart(page);
|
||||
assert.equal(await page.evaluate(()=>window.rhine.stats().audio.tracks),3);
|
||||
report.checks.push({name:'Failed music leaves opening paused; retry downloads and starts all tracks'});await context.close();
|
||||
}
|
||||
{
|
||||
const {context,page}=await fresh();let unblock;
|
||||
const blocked=new Promise(r=>unblock=r);
|
||||
await page.route('**/audio/*.ogg',async route=>{await blocked;await route.continue().catch(()=>{});});
|
||||
await page.goto(base);await waitEntry(page);await page.locator('.entry-start').click();
|
||||
await page.waitForFunction(()=>window.rhine.stats().startup==='starting');
|
||||
await page.locator('.entry-start').click({force:true});assert.equal(await page.evaluate(()=>window.rhine.stats().startup),'starting');
|
||||
await page.locator('.entry-silent').click();unblock();await waitStart(page);await page.waitForTimeout(500);
|
||||
const state=await page.evaluate(()=>window.rhine.stats());assert.equal(state.audio.tracks,0);assert.equal(state.audio.preferences.sound,false);assert.equal(state.audio.preferences.music,false);
|
||||
report.checks.push({name:'Repeated entry is ignored; silent entry cancels pending music and does not start late'});await context.close();
|
||||
}
|
||||
{
|
||||
const {context,page}=await fresh();await page.goto(base+'?time=6.2&freeze=1');await waitStart(page);
|
||||
assert.equal(await page.evaluate(()=>window.rhine.stats().bootTime),11.2);
|
||||
await page.waitForTimeout(500);assert.equal(await page.evaluate(()=>window.rhine.stats().bootTime),11.2);
|
||||
report.checks.push({name:'Frame review bypasses entry and preserves reference time'});await context.close();
|
||||
}
|
||||
}
|
||||
assert.deepEqual(report.errors,[]);console.log(JSON.stringify(report,null,2));
|
||||
} finally {await writeFile(resolve(output,`startup-${engine}-${process.env.REVIEW_CHANNEL||'chrome'}.json`),JSON.stringify(report,null,2));await browser.close();}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Run against a built preview; optionally set REVIEW_CHANNEL=msedge.
|
||||
import assert from 'node:assert/strict';
|
||||
import {mkdir, writeFile} from 'node:fs/promises';
|
||||
import {resolve} from 'node:path';
|
||||
import {pathToFileURL} from 'node:url';
|
||||
const {chromium}=await import(process.env.PLAYWRIGHT_MODULE?pathToFileURL(resolve(process.env.PLAYWRIGHT_MODULE)).href:'playwright');
|
||||
const channel=process.env.REVIEW_CHANNEL || 'chrome';
|
||||
const browser=await chromium.launch({channel,headless:true,args:['--use-angle=d3d11','--enable-gpu','--ignore-gpu-blocklist']});
|
||||
const report={channel,version:browser.version(),checks:[]};
|
||||
try {for(const reducedMotion of ['no-preference','reduce']) {
|
||||
const context=await browser.newContext({viewport:{width:1440,height:900},reducedMotion,serviceWorkers:'block'});
|
||||
const page=await context.newPage(),errors=[];page.on('pageerror',error=>errors.push(error.message));
|
||||
const loaded=async()=>{
|
||||
await page.waitForFunction(()=>window.rhine?.stats().ready);
|
||||
if(await page.locator('.entry-start').count())await page.locator('.entry-start').click();
|
||||
await page.waitForFunction(()=>!document.querySelector('#loading'));
|
||||
};
|
||||
await page.goto(process.env.REVIEW_URL || 'http://127.0.0.1:5190/');await loaded();
|
||||
let state=await page.evaluate(()=>window.rhine.stats());
|
||||
assert.equal(state.mode,reducedMotion==='reduce'?'archive':'boot');
|
||||
assert.equal(state.motion.reduced,reducedMotion==='reduce');
|
||||
await page.evaluate(()=>window.rhine.archive());
|
||||
await page.getByRole('button',{name:'系统设置',exact:true}).click();
|
||||
assert.equal(await page.locator('.settings-label').textContent(),'设置');
|
||||
const checkbox=page.locator('[data-pref="reduced"]');
|
||||
assert.equal(await checkbox.isChecked(),reducedMotion==='reduce');
|
||||
if(reducedMotion==='reduce') {
|
||||
await page.getByRole('button',{name:'启用完整动效并重播'}).click();
|
||||
await page.waitForFunction(()=>window.rhine.stats().mode==='boot');
|
||||
assert.equal(await page.locator('.modal-backdrop').count(),0);
|
||||
state=await page.evaluate(()=>window.rhine.stats());
|
||||
assert.equal(state.motion.reduced,false);assert.equal(state.motion.systemReduced,true);
|
||||
await page.evaluate(()=>window.rhine.archive());await page.waitForTimeout(1000);
|
||||
await page.locator('[data-action="next"]').click();await page.waitForTimeout(150);
|
||||
assert.equal(await page.locator('#stage').evaluate(el=>el.classList.contains('reduce-motion')),false);
|
||||
assert.ok((await page.evaluate(()=>window.rhine.stats().pulses.length))>0);
|
||||
await page.locator('.read-file').click();
|
||||
// The app override also restores document reveals under an OS reduce preference.
|
||||
await page.waitForSelector('.document-redaction-window',{state:'attached'});
|
||||
assert.equal(await page.locator('.document-redaction-window').first().evaluate(el=>getComputedStyle(el).display),'block');
|
||||
await page.reload();await loaded();assert.equal((await page.evaluate(()=>window.rhine.stats())).mode,'boot');
|
||||
}
|
||||
assert.deepEqual(errors,[]);report.checks.push({reducedMotion,passed:true});await context.close();
|
||||
}}finally{await browser.close()}
|
||||
await mkdir('.tools/responsive',{recursive:true});await writeFile(`.tools/responsive/startup-${channel}.json`,JSON.stringify(report,null,2));console.log(JSON.stringify(report,null,2));
|
||||
@@ -0,0 +1,26 @@
|
||||
import {createRequire} from 'node:module';
|
||||
import {mkdir,writeFile} from 'node:fs/promises';
|
||||
import assert from 'node:assert/strict';
|
||||
const require=createRequire(import.meta.url),{chromium}=require(process.env.PLAYWRIGHT_MODULE || 'playwright');
|
||||
const browser=await chromium.launch({channel:'msedge',headless:true});
|
||||
const page=await browser.newPage({viewport:{width:1920,height:1080},deviceScaleFactor:1});
|
||||
const errors=[];page.on('pageerror',e=>errors.push(String(e)));page.on('console',m=>{if(m.type()==='error'&&/THREE|shader|WebGL/.test(m.text()))errors.push(m.text())});
|
||||
await page.goto('http://127.0.0.1:5176/?scene=archive');await page.waitForFunction(()=>window.rhine?.stats().ready);
|
||||
const apply=async(values,wait=1500)=>{await page.evaluate(values=>window.wallpaperPropertyListener.applyUserProperties(Object.fromEntries(Object.entries(values).map(([k,value])=>[k,{value}]))),values);await page.waitForTimeout(wait)};
|
||||
await apply({boot:false,desktopmode:'workbench',sound:false,music:false,reduced:false,renderquality:'original',hudparallax:true,huddepth:20,hudtracking:false,uifrost:true,screenfinish:true,screengrain:20,screenfringe:20,screenvignette:20,audioreactive:true,selectionstyle:'original'});
|
||||
await page.evaluate(()=>window.wallpaperPropertyListener.applyGeneralProperties({fps:60}));
|
||||
const measure=()=>page.evaluate(()=>{const host=document.querySelector('#three-scene'),canvas=host.querySelector('canvas'),gl=canvas.getContext('webgl2'),ext=gl.getExtension('WEBGL_debug_renderer_info');return {quality:JSON.parse(host.dataset.renderQuality),stats:window.rhine.stats(),font:getComputedStyle(document.querySelector('.wb-clock')).fontSize,screen:document.querySelector('#stage').dataset.screenFinish,gpu:ext?gl.getParameter(ext.UNMASKED_RENDERER_WEBGL):gl.getParameter(gl.RENDERER)}});
|
||||
await mkdir('verification/super-performance',{recursive:true});
|
||||
await page.waitForTimeout(6000);const original=await measure();await page.screenshot({path:'verification/super-performance/original.png'});
|
||||
await apply({superperformance:true},6000);const fast=await measure();await page.screenshot({path:'verification/super-performance/super.png'});
|
||||
assert.equal(fast.stats.superPerformance,true);assert.equal(fast.stats.motion.reduced,false);assert.equal(fast.quality.shadows,0);assert.equal(fast.quality.aoSamples,0);assert.equal(fast.quality.depthOfField,0);assert.equal(fast.screen,'false');assert.equal(fast.font,original.font);assert.ok(fast.quality.width*fast.quality.height<=921600);assert.ok(fast.stats.drawCalls<original.stats.drawCalls);assert.ok(fast.stats.triangles<original.stats.triangles);
|
||||
await page.evaluate(()=>window.rhine.select(9));await page.waitForTimeout(180);assert.equal((await measure()).stats.selected,'X-010');
|
||||
await apply({desktopmode:'archive'});await page.waitForTimeout(1000);await page.evaluate(()=>window.rhine.detail());await page.waitForTimeout(3500);assert.equal((await measure()).stats.mode,'detail');await page.screenshot({path:'verification/super-performance/detail.png'});
|
||||
await page.evaluate(()=>window.rhine.archive());await page.waitForTimeout(1200);
|
||||
await apply({superperformance:false},4000);const restored=await measure();assert.equal(restored.quality.width,original.quality.width);assert.equal(restored.quality.shadows,original.quality.shadows);assert.equal(restored.screen,'true');assert.equal(restored.stats.motion.reduced,false);
|
||||
await apply({renderquality:'custom',qualityscale:75,qualityshadows:'0',qualityaosamples:'0',qualitydepthoffield:0,qualitytransmission:'0.5',qualitypixelratio:'1'});const custom=await measure();assert.equal(custom.quality.shadows,0);assert.equal(custom.quality.transmission,.5);assert.equal(custom.quality.width,1440);
|
||||
await apply({qualityscale:65});const partial=await measure();assert.equal(partial.quality.width,1248);assert.equal(partial.quality.transmission,.5);
|
||||
await apply({superperformance:true});await apply({qualityscale:85});assert.ok((await measure()).quality.width<=1280);
|
||||
await apply({superperformance:false});const customRestored=await measure();assert.equal(customRestored.quality.width,1632);assert.equal(customRestored.quality.transmission,.5);
|
||||
await page.setViewportSize({width:3840,height:2160});await apply({superperformance:true});const uhd=await measure();assert.ok(uhd.quality.width*uhd.quality.height<=921600);
|
||||
assert.deepEqual(errors,[]);await writeFile('verification/super-performance/results.json',JSON.stringify({original,fast,restored,custom,partial,customRestored,uhd,errors},null,2));console.log(JSON.stringify({original:{pixels:original.quality.width*original.quality.height,calls:original.stats.drawCalls,triangles:original.stats.triangles,fps:original.stats.fps},fast:{pixels:fast.quality.width*fast.quality.height,calls:fast.stats.drawCalls,triangles:fast.stats.triangles,fps:fast.stats.fps},gpu:fast.gpu,checks:'motion, detail, restore, custom partial callbacks, override recovery, 4K pixel budget passed'}));await browser.close();
|
||||
@@ -0,0 +1,12 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import ts from 'typescript';
|
||||
const source=ts.transpileModule(readFileSync('src/theme-motion.ts','utf8'),{compilerOptions:{module:ts.ModuleKind.ESNext,target:ts.ScriptTarget.ES2022}}).outputText;
|
||||
const {ThemeWave}=await import(`data:text/javascript;base64,${Buffer.from(source).toString('base64')}`);
|
||||
const wave=new ThemeWave(),center={lane:2,row:12},far={lane:5,row:20};
|
||||
wave.set(true,0,center);wave.beginFrame();const a=wave.sample(center,.3),b=wave.sample(far,.3);assert.ok(a>b&&a>0&&a<1,'Origin changes first');
|
||||
wave.set(false,.3,center);assert.equal(wave.sample(center,.3),a);assert.equal(wave.sample(far,.3),b,'Reversal preserves each current card');
|
||||
assert.equal(wave.sample(center,2),0);assert.equal(wave.sample(far,2),0);assert.equal(wave.background(2),0);
|
||||
wave.set(true,3,center,true);assert.equal(wave.sample(far,3),1);assert.equal(wave.background(3),1,'Reduced motion goes directly to target');
|
||||
wave.set(false,4,center);wave.beginFrame();for(let i=-100;i<100;i++){const n=wave.sample({lane:i,row:i},4.2);assert.ok(n>=0&&n<=1)}
|
||||
console.log('Theme cascade, reversal continuity, endpoints, reduced motion and bounded values passed.');
|
||||
@@ -0,0 +1,52 @@
|
||||
import {createRequire} from 'node:module';
|
||||
import {mkdir,writeFile} from 'node:fs/promises';
|
||||
import assert from 'node:assert/strict';
|
||||
const require=createRequire(import.meta.url),{chromium}=require(process.env.PLAYWRIGHT_MODULE||'playwright');
|
||||
const browser=await chromium.launch({channel:'msedge',headless:true});
|
||||
try {
|
||||
const page=await browser.newPage({viewport:{width:1600,height:900}}),errors=[];
|
||||
page.on('pageerror',e=>errors.push(String(e)));page.on('console',m=>{if(m.type()==='error'&&/THREE|shader|WebGL/.test(m.text()))errors.push(m.text())});
|
||||
await page.goto('http://127.0.0.1:5176/?scene=archive');await page.waitForFunction(()=>window.rhine?.stats().ready);
|
||||
const apply=async(values)=>{await page.evaluate(values=>window.wallpaperPropertyListener.applyUserProperties(Object.fromEntries(Object.entries(values).map(([k,value])=>[k,{value}]))),values);await page.waitForTimeout(300)};
|
||||
const stats=()=>page.evaluate(()=>window.rhine.stats());
|
||||
const click=()=>page.locator('[data-action="toggle-three"]').click();
|
||||
await apply({desktopmode:'workbench',boot:false,superperformance:true,screenfinish:false,hudparallax:false,uifrost:false,sound:false,music:false,reduced:false});
|
||||
await page.waitForFunction(()=>window.rhine.stats().mode==='archive');
|
||||
await page.evaluate(()=>{window.contextLosses=0;const canvas=document.querySelector('#three-scene canvas');canvas.addEventListener('webglcontextlost',()=>window.contextLosses++);window.oldCanvas=canvas});
|
||||
const before=await stats();
|
||||
await click();await page.waitForTimeout(150);const closing=await stats();assert.equal(closing.threeState,'closing');assert.ok(closing.presentation<1&&closing.presentation>0);
|
||||
await click();await page.waitForFunction(()=>window.rhine.stats().presentation===1);assert.equal(await page.evaluate(()=>window.contextLosses),0);
|
||||
const picture='data:image/svg+xml,'+encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="900"><rect width="1600" height="900" fill="#bfd0db"/><circle cx="900" cy="380" r="260" fill="#dfd5be"/><path d="M0 790L1100 560L1600 730V900H0Z" fill="#8da69d"/></svg>');
|
||||
await apply({customwallpaper:true,customwallpaperfile:picture});
|
||||
await page.waitForFunction(()=>document.querySelector('.wallpaper-background').dataset.ready==='true');
|
||||
assert.equal(await page.locator('.wallpaper-background').evaluate(el=>getComputedStyle(el).opacity),'0');
|
||||
await click();await page.waitForFunction(()=>window.rhine.stats().threeState==='off');await page.waitForTimeout(800);
|
||||
assert.equal(await page.locator('#three-scene canvas').count(),0);assert.equal(await page.evaluate(()=>window.contextLosses),1);
|
||||
assert.equal(await page.locator('.relay-entry').isVisible(),false);
|
||||
assert.equal(await page.locator('.wallpaper-background').evaluate(el=>getComputedStyle(el).opacity),'1');
|
||||
await mkdir('verification/three-release',{recursive:true});await page.screenshot({path:'verification/three-release/custom-wallpaper.png'});
|
||||
await click();await page.waitForFunction(()=>window.rhine.stats().threeState==='on');const reloading=await stats();assert.ok(reloading.presentation<1);
|
||||
await page.waitForFunction(()=>window.rhine.stats().presentation===1);await page.waitForTimeout(800);
|
||||
assert.equal(await page.locator('.wallpaper-background').evaluate(el=>getComputedStyle(el).opacity),'0');assert.equal(await page.locator('#three-scene canvas').count(),1);
|
||||
assert.equal(await page.evaluate(()=>window.oldCanvas===document.querySelector('#three-scene canvas')),false);
|
||||
assert.equal((await stats()).selected,before.selected);
|
||||
await page.waitForFunction(()=>!document.querySelector('.relay-entry').hidden);
|
||||
assert.equal(await page.locator('.relay-entry').isVisible(),true);
|
||||
// Reduced motion also disposes; toggling the optional picture never recreates WebGL.
|
||||
await apply({reduced:true});await click();await page.waitForFunction(()=>window.rhine.stats().threeState==='off');
|
||||
await apply({customwallpaper:false});assert.equal(await page.locator('.wallpaper-background').evaluate(el=>getComputedStyle(el).opacity),'0');
|
||||
await apply({customwallpaper:true});assert.equal(await page.locator('.wallpaper-background').evaluate(el=>getComputedStyle(el).opacity),'1');
|
||||
await apply({customwallpaperfile:''});assert.equal(await page.locator('.wallpaper-background img').count(),0);
|
||||
assert.equal(await page.locator('canvas').count(),0);
|
||||
await click();await page.waitForFunction(()=>window.rhine.stats().threeState==='on'&&window.rhine.stats().presentation===1);
|
||||
await apply({desktopmode:'archive'});await page.evaluate(()=>window.rhine.detail());await page.waitForFunction(()=>window.rhine.stats().mode==='detail');
|
||||
await page.locator('[data-action="model-viewer"]').click();await page.waitForTimeout(1800);
|
||||
assert.equal(await page.locator('canvas').count(),2);await page.keyboard.press('Escape');await page.waitForTimeout(400);
|
||||
await click();await page.waitForFunction(()=>window.rhine.stats().threeState==='off');assert.equal(await page.locator('canvas').count(),0);
|
||||
// Details stay readable with no renderer.
|
||||
assert.equal(await page.locator('#detail-content').evaluate(el=>el.inert),false);
|
||||
await click();await page.waitForFunction(()=>window.rhine.stats().threeState==='on');
|
||||
await page.screenshot({path:'verification/three-release/restored.png'});
|
||||
assert.deepEqual(errors,[]);await writeFile('verification/three-release/results.json',JSON.stringify({before,closing,reloading,after:await stats(),errors},null,2));
|
||||
console.log('Exit reversal, actual context loss, zero canvases, custom image, fresh context, reduced motion, viewer disposal and detail recovery passed.');
|
||||
} finally {await browser.close()}
|
||||
@@ -0,0 +1,29 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { viewportLayout, openingLayout, archiveFraming } from "../src/viewport-layout.ts";
|
||||
import { renderDimensions, qualityPresets } from "../src/render-quality.ts";
|
||||
|
||||
for (const [w,h,touch] of [[1920,1080,false],[2560,1080,false],[1280,1024,false],[844,390,true],[390,844,true],[320,568,true]]) {
|
||||
const layout=viewportLayout(w,h,touch);
|
||||
assert.ok(Math.abs(layout.width*layout.scale-w)<1e-8);
|
||||
assert.ok(Math.abs(layout.height*layout.scale-h)<1e-8);
|
||||
const film=viewportLayout(w,h,touch,true);
|
||||
const opening=openingLayout(w,h);
|
||||
assert.ok(Math.abs(opening.width*opening.scale-w)<1e-8 && Math.abs(opening.height*opening.scale-h)<1e-8,'Opening fills the viewport');
|
||||
assert.ok(opening.width>=1279.99 && opening.height>=1079.99,'Central login content fits without stretching');
|
||||
assert.equal(film.width/film.height,16/9);
|
||||
assert.ok(film.width*film.scale<=w+.001&&film.height*film.scale<=h+.001);
|
||||
const shot=archiveFraming(layout.width,layout.height,7.33,1,layout.kind==='compact');
|
||||
assert.ok(shot.span>=5.9&&shot.detailX>0&&shot.detailX<1&&shot.detailY>0&&shot.detailY<1);
|
||||
if(layout.kind==='portrait') {
|
||||
const coverHeight=3.7*h/shot.span;
|
||||
assert.ok(shot.detailY*h-coverHeight/2>=110, 'Cover clears the return control on short phones');
|
||||
assert.ok(shot.detailY*h+coverHeight/2<=h*.54-40, 'Cover clears the document header');
|
||||
}
|
||||
const render=renderDimensions(qualityPresets.original,layout.width,layout.height,layout.scale,3,16384);
|
||||
assert.ok(render.width>=w, 'CSS-sized phones must not retain the old 1920px scale factor');
|
||||
assert.ok(render.width*render.height<=8294400+5000);
|
||||
}
|
||||
assert.deepEqual(viewportLayout(1920,1080,false),{width:1920,height:1080,scale:1,kind:'desktop'});
|
||||
assert.equal(archiveFraming(1920,1080,7.33,1,false).span,5.9);
|
||||
assert.equal(archiveFraming(1920,1080,7.33,1,false).detailX,550/1920);
|
||||
console.log('Viewport, reference framing and render resolution checks passed.');
|
||||
@@ -0,0 +1,51 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import ts from 'typescript';
|
||||
const moduleFrom = async source => import('data:text/javascript;base64,'+Buffer.from(ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.ESNext,target:ts.ScriptTarget.ES2022}}).outputText).toString('base64'));
|
||||
const {projectHudPoint,hudQuadMatrix}=await moduleFrom(readFileSync('src/hud-projection.ts','utf8'));
|
||||
const zero={x:0,y:0};const close=(a,b)=>assert.ok(Math.abs(a-b)<1e-6,`${a} != ${b}`);
|
||||
for(const [width,height] of [[1920,1080],[3440,1440],[390,844]]) {
|
||||
for(const point of [{x:0,y:0},{x:width,y:height},{x:width*.12,y:height*.3}]) {
|
||||
const original=projectHudPoint(point,width,height,0,zero);close(original.x,point.x);close(original.y,point.y);
|
||||
const a=projectHudPoint(point,width,height,.7,zero),b=projectHudPoint({x:width-point.x,y:point.y},width,height,.7,zero);
|
||||
close(a.x+b.x,width);close(a.y,b.y);
|
||||
}
|
||||
}
|
||||
const tl=projectHudPoint({x:60,y:114},1920,1080,.7,zero),tr=projectHudPoint({x:330,y:114},1920,1080,.7,zero);
|
||||
assert.ok(tr.y>tl.y,'Top-left line slopes inward/down');
|
||||
const rtl=projectHudPoint({x:1590,y:114},1920,1080,.7,zero),rtr=projectHudPoint({x:1860,y:114},1920,1080,.7,zero);
|
||||
assert.ok(rtr.y<rtl.y,'Top-right line slopes oppositely');
|
||||
const quad=[{x:12,y:20},{x:380,y:40},{x:340,y:220},{x:5,y:240}],matrix=hudQuadMatrix(400,200,quad);
|
||||
for(const [i,p] of [[0,[0,0]],[1,[400,0]],[2,[400,200]],[3,[0,200]]]) {
|
||||
const d=matrix[3]*p[0]+matrix[7]*p[1]+1;
|
||||
close((matrix[0]*p[0]+matrix[4]*p[1]+matrix[12])/d,quad[i].x);
|
||||
close((matrix[1]*p[0]+matrix[5]*p[1]+matrix[13])/d,quad[i].y);
|
||||
}
|
||||
const events={};globalThis.window={addEventListener:(key,fn)=>events[key]=fn};globalThis.document={addEventListener(){}};
|
||||
globalThis.effectProbe={};
|
||||
const mocks=`const wallpaperHost=()=>undefined;class HudProjection{constructor(stage){this.stage=stage}invalidate(){}update(depth,pointer){globalThis.effectProbe.depth=depth;globalThis.effectProbe.pointer={...pointer};this.stage.dataset.hudDepth=String(depth>.00001)}}class ScreenFinish{update(...args){globalThis.effectProbe.screen=args}}`;
|
||||
const input=readFileSync('src/wallpaper-effects.ts','utf8').replace(/^import .*;\r?\n/gm,'');
|
||||
const {WallpaperEffects,effectOptions,wallpaperInsets}=await moduleFrom(mocks+input);
|
||||
assert.deepEqual(wallpaperInsets({}),{top:0,right:0,bottom:0,left:0});
|
||||
assert.deepEqual(wallpaperInsets({uimargintop:{value:-40},uimarginright:{value:25},uimarginbottom:{value:60},uimarginleft:{value:-10}}),{top:-40,right:25,bottom:60,left:-10});
|
||||
assert.deepEqual(wallpaperInsets({uimargintop:{value:-999},uimarginright:{value:Infinity},uimarginbottom:{value:999},uimarginleft:{value:'20'}}),{top:-300,right:0,bottom:300,left:0});
|
||||
assert.equal(effectOptions({}).parallax,false);assert.equal(effectOptions({}).tracking,true);
|
||||
assert.equal(effectOptions({huddepth:{value:Infinity}}).depth,.2);assert.equal(effectOptions({huddepth:{value:999}}).depth,1);assert.equal(effectOptions({uifroststrength:{value:-3}}).frostStrength,0);
|
||||
const pointer={},css={};let modal=0;
|
||||
const stage={dataset:{mode:'archive'},style:{setProperty:(k,v)=>css[k]=v},addEventListener:(k,fn)=>pointer[k]=fn,getBoundingClientRect:()=>({left:0,top:0,width:1000,height:800}),querySelectorAll:()=>[],querySelector:()=>({childElementCount:modal})};
|
||||
const scene={setSelectedIndexAccent(){}};const fx=new WallpaperEffects(stage,()=>scene),probe=globalThis.effectProbe;
|
||||
const props=p=>events['rhine-wallpaper-properties']({detail:Object.fromEntries(Object.entries(p).map(([k,value])=>[k,{value}]))});
|
||||
props({hudparallax:true,uifrost:true,screenfinish:true});pointer.pointermove({pointerType:'mouse',clientX:1000,clientY:0});
|
||||
for(let i=1;i<120;i++)fx.update(i/60,false);
|
||||
assert.equal(scene.uiOnlyParallax,true);assert.equal(stage.dataset.uiFrost,'true');assert.equal(probe.screen[0],true);assert.ok(probe.pointer.x>.99);
|
||||
props({hudtracking:false});for(let i=120;i<240;i++)fx.update(i/60,false);
|
||||
assert.ok(probe.depth>.19,'Tracking off retains static curved HUD');assert.ok(Math.abs(probe.pointer.x)<.001,'Tracking off returns to centered lens');
|
||||
props({hudtracking:true});modal=1;for(let i=240;i<360;i++)fx.update(i/60,false);assert.ok(Math.abs(probe.pointer.x)<.001);
|
||||
modal=0;fx.update(6,true);assert.equal(probe.pointer.x,0);assert.ok(probe.depth>.19,'Reduced motion keeps stationary projection');
|
||||
stage.dataset.mode='boot';fx.update(6.1,false);assert.ok(probe.depth>.19,'Opening shares the enabled HUD depth');assert.equal(stage.dataset.uiFrost,'false');assert.equal(probe.screen[0],true,'Global finish also covers opening');
|
||||
props({hudparallax:false,screenfinish:false});fx.update(6.2,false);assert.equal(scene.uiOnlyParallax,true,'Wallpaper camera never follows passive cursor input');assert.equal(probe.screen[0],false);
|
||||
props({uimarginbottom:60,uimarginleft:-20});fx.update(6.3,false);
|
||||
assert.equal(stage.dataset.uiInsets,'true');assert.equal(css['--ui-bottom'],'calc(60px / var(--stage-scale, 1))');assert.equal(css['--ui-left'],'calc(-20px / var(--stage-scale, 1))');
|
||||
props({uimargintop:30});fx.update(6.4,false);assert.equal(css['--ui-bottom'],'calc(60px / var(--stage-scale, 1))','Partial side change retains the other sides');
|
||||
props({uimargintop:0,uimarginbottom:0,uimarginleft:0});fx.update(6.5,false);assert.equal(stage.dataset.uiInsets,'false','All zero restores baseline layout');
|
||||
console.log('HUD radial symmetry, opposite slopes, projective corners, tracking independence, modal/reduced motion and global screen scope passed.');
|
||||
@@ -0,0 +1,26 @@
|
||||
import {createServer} from 'node:http';
|
||||
import {spawn} from 'node:child_process';
|
||||
import {readFile,writeFile,mkdir} from 'node:fs/promises';
|
||||
import {resolve} from 'node:path';
|
||||
import assert from 'node:assert/strict';
|
||||
const dir=resolve('verification/wallpaper-image/host');await mkdir(dir,{recursive:true});
|
||||
const source=await readFile('verification/wallpaper-image/test.html','utf8');
|
||||
const raw='E:/AIProject/RhineLabUI/reference/extracted-3088099655/wallpaper-2.jpg';
|
||||
const exe='D:/Game/Steam/steamapps/common/wallpaper_engine/wallpaper64.exe';
|
||||
const location='Rhine Lab image diagnostic';
|
||||
const run=args=>new Promise((ok,no)=>{const child=spawn(exe,args,{windowsHide:true,stdio:'ignore'});child.once('error',no);child.once('exit',ok)});
|
||||
let finish;const result=new Promise(resolve=>finish=resolve);
|
||||
const server=createServer((req,res)=>{let body='';req.on('data',b=>body+=b);req.on('end',()=>{res.setHeader('Access-Control-Allow-Origin','*');res.end('ok');try{finish(JSON.parse(body))}catch{}})});
|
||||
await new Promise(resolve=>server.listen(5183,'127.0.0.1',resolve));
|
||||
const probe=`window.wallpaperPropertyListener={applyUserProperties(props){if(!props.customwallpaperfile)return;const raw=props.customwallpaperfile.value,url=wallpaperImageUrl(raw),image=new Image();const report=ok=>fetch('http://127.0.0.1:5183/',{method:'POST',body:JSON.stringify({ok,raw,url,width:image.naturalWidth,height:image.naturalHeight})});image.onload=()=>report(true);image.onerror=()=>report(false);image.src=url;document.body.append(image)}};`;
|
||||
await writeFile(dir+'/index.html',source.replace('</script>',probe+'</script>'));
|
||||
await writeFile(dir+'/project.json',JSON.stringify({title:'Rhine Lab image diagnostic',type:'web',file:'index.html',general:{properties:{customwallpaperfile:{type:'file',value:raw,text:'image'}}}}));
|
||||
let timeout;
|
||||
try {
|
||||
await run(['-control','openWallpaper','-file',dir+'/project.json','-playInWindow',location,'-width','320','-height','200','-x','-30000','-y','-30000']);
|
||||
const data=await Promise.race([result,new Promise((_,reject)=>{timeout=setTimeout(()=>reject(Error('No host result within 30 seconds')),30000)})]);
|
||||
await writeFile('verification/wallpaper-image/host-results.json',JSON.stringify(data,null,2));console.log(data);
|
||||
assert.equal(data.ok,true);assert.equal(data.width,3200);assert.equal(data.height,2000);
|
||||
}finally{
|
||||
clearTimeout(timeout);server.close();await run(['-control','closeWallpaper','-location',location]);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {createRequire} from 'node:module';
|
||||
import {readFile,writeFile,mkdir,copyFile,rm} from 'node:fs/promises';
|
||||
import {resolve} from 'node:path';
|
||||
import {pathToFileURL} from 'node:url';
|
||||
import {wallpaperImageUrl} from '../src/wallpaper-image-url.ts';
|
||||
const raw='E:/AIProject/RhineLabUI/reference/extracted-3088099655/wallpaper-2.jpg';
|
||||
const expected='file:///'+raw;
|
||||
for(const path of [raw,raw.replaceAll('/','\\'),'E%3A'+raw.slice(2),encodeURIComponent(raw),'file:///E%3A'+raw.slice(2),expected])
|
||||
assert.equal(wallpaperImageUrl(path),expected,path);
|
||||
assert.equal(wallpaperImageUrl('C:/图片/100% #1.jpg'),'file:///C:/%E5%9B%BE%E7%89%87/100%25%20%231.jpg');
|
||||
assert.equal(wallpaperImageUrl('file:///C:/folder/a%20b.jpg'),'file:///C:/folder/a%20b.jpg');
|
||||
const require=createRequire(import.meta.url),ts=require('typescript');
|
||||
const {chromium}=require(process.env.PLAYWRIGHT_MODULE||'playwright');
|
||||
const dir=resolve('verification/wallpaper-image');await mkdir(dir,{recursive:true});
|
||||
const sources=await Promise.all(['src/wallpaper-image-url.ts','src/wallpaper-background.ts'].map(p=>readFile(p,'utf8')));
|
||||
const js=ts.transpileModule(sources.join('\n').replace(/^import .*;\s*$/gm,'').replace(/^export /gm,''),{compilerOptions:{target:ts.ScriptTarget.ES2022}}).outputText;
|
||||
await writeFile(dir+'/test.html',`<!doctype html><div id="stage"><div id="three-scene"></div></div><script>${js}\nwindow.background=new WallpaperBackground(document.querySelector('#stage'),message=>(window.messages??=[]).push(message));</script>`);
|
||||
const browser=await chromium.launch({channel:'msedge',headless:true});
|
||||
try {
|
||||
const page=await browser.newPage();await page.goto(pathToFileURL(dir+'/test.html').href);
|
||||
const apply=(path,retry=false)=>page.evaluate(({path,retry})=>background.update({customwallpaper:{value:true},customwallpaperfile:{value:path}},true,true,retry),{path,retry});
|
||||
await apply('E%3A'+raw.slice(2));await page.waitForFunction(()=>document.querySelector('.wallpaper-background').dataset.ready==='true');
|
||||
assert.deepEqual(await page.locator('.wallpaper-background img').evaluate(el=>[el.naturalWidth,el.naturalHeight]),[3200,2000]);
|
||||
// Fail, create the same file, then reselect exactly the same path.
|
||||
const retryPath=dir+'/retry-'+Date.now()+'.jpg';
|
||||
await apply(retryPath);await page.waitForFunction(()=>window.messages?.length===1);
|
||||
await copyFile(raw,retryPath);
|
||||
await apply(retryPath,true);await page.waitForFunction(()=>document.querySelector('.wallpaper-background').dataset.ready==='true');
|
||||
assert.equal(await page.locator('.wallpaper-background img').last().evaluate(el=>el.naturalWidth),3200);
|
||||
await rm(retryPath);
|
||||
await writeFile(dir+'/results.json',JSON.stringify({raw,encoded:'E%3A'+raw.slice(2),url:expected,dimensions:[3200,2000],samePathRetry:true},null,2));
|
||||
console.log('Raw/escaped drive, whole escaped path, special characters, real local JPEG and same-path retry passed.');
|
||||
}finally{await browser.close()}
|
||||
@@ -0,0 +1,10 @@
|
||||
import {createRequire} from 'node:module';
|
||||
import {readFile,mkdir,writeFile} from 'node:fs/promises';
|
||||
import {pathToFileURL} from 'node:url';
|
||||
import {resolve} from 'node:path';
|
||||
import assert from 'node:assert/strict';
|
||||
const require=createRequire(import.meta.url),{chromium}=require(process.env.PLAYWRIGHT_MODULE||'playwright');
|
||||
const browser=await chromium.launch({channel:'msedge',headless:true});
|
||||
try{const page=await browser.newPage({viewport:{width:1280,height:720}});await page.goto(pathToFileURL(resolve('verification/wallpaper-image/test.html')).href);for(const file of ['src/style.css','src/theme.css','src/wallpaper-effects.css'])await page.evaluate(css=>{const style=document.createElement('style');style.textContent=css;document.head.append(style)},await readFile(file,'utf8'));await page.evaluate(()=>{document.querySelector('#stage').style.cssText='position:fixed;inset:0';document.querySelector('#stage').insertAdjacentHTML('beforeend','<div class="scene-atmosphere archive-atmosphere" style="opacity:1"></div>')});
|
||||
const results=[];for(const dark of [false,true])for(const value of [100,50,0]){await page.evaluate(({dark,value})=>{document.documentElement.dataset.darkSurface=String(dark);document.documentElement.style.setProperty('--theme-paper-rgb',dark?'40,44,48':'234,229,225');background.update({customwallpaper:{value:true},customwallpaperfile:{value:'E:/AIProject/RhineLabUI/reference/extracted-3088099655/wallpaper-2.jpg'},customwallpapermask:{value}},true,true)},{dark,value});await page.waitForFunction(()=>document.querySelector('.wallpaper-background').dataset.ready==='true');await page.waitForTimeout(100);const result=await page.locator('.scene-atmosphere').evaluate(el=>({opacity:getComputedStyle(el).opacity,size:getComputedStyle(el).backgroundSize}));assert.equal(result.opacity,value===0?'0':'1');assert.ok(result.size.includes(`${value}%`));results.push({dark,value,...result});await mkdir('verification/wallpaper-mask',{recursive:true});await page.screenshot({path:`verification/wallpaper-mask/${dark?'dark':'light'}-${value}.png`})}await writeFile('verification/wallpaper-mask/results.json',JSON.stringify(results,null,2));console.log('Light/dark mask ranges 100, 50, 0 passed.')}finally{await browser.close()}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import vm from "node:vm";
|
||||
import ts from "typescript";
|
||||
|
||||
const events = [];
|
||||
const hostWindow = { dispatchEvent: event => events.push(event) };
|
||||
vm.runInNewContext(readFileSync("wallpaper/host.js", "utf8"), {
|
||||
window: hostWindow, Event, CustomEvent,
|
||||
});
|
||||
const listener = hostWindow.wallpaperPropertyListener;
|
||||
listener.applyUserProperties({ sound: { value: false } });
|
||||
listener.applyUserProperties({ musicvolume: { value: 15 } });
|
||||
assert.equal(hostWindow.rhineWallpaperHost.properties.sound.value, false);
|
||||
assert.equal(hostWindow.rhineWallpaperHost.properties.musicvolume.value, 15);
|
||||
assert.equal(events.length, 2, "Early and partial callbacks are retained and dispatched");
|
||||
listener.applyGeneralProperties({ fps: 30 });
|
||||
listener.applyGeneralProperties({ fps: NaN });
|
||||
assert.equal(hostWindow.rhineWallpaperHost.fps, 30);
|
||||
globalThis.window = hostWindow;
|
||||
const documentListeners = {};
|
||||
globalThis.document = { documentElement: { dataset: {} }, addEventListener: (type, handler) => { documentListeners[type] = handler; } };
|
||||
const source = readFileSync("src/wallpaper.ts", "utf8").replace('import.meta.env.MODE', '"wallpaper"');
|
||||
const { outputText } = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } });
|
||||
const wallpaperModule = `data:text/javascript;base64,${Buffer.from(outputText).toString("base64")}`;
|
||||
const { wallpaperFrame } = await import(wallpaperModule);
|
||||
let rendered = 0;
|
||||
for (let i = 0; i < 120; i++) if (wallpaperFrame(i * 1000 / 60)) rendered++;
|
||||
assert.ok(rendered >= 59 && rendered <= 61, `30 FPS host limit at 60 Hz: ${rendered} / 2 s`);
|
||||
listener.setPaused(true);
|
||||
assert.equal(wallpaperFrame(10000), false);
|
||||
listener.setPaused(false);
|
||||
assert.equal(wallpaperFrame(10001), true, "Resume renders immediately without catch-up frames");
|
||||
listener.applyGeneralProperties({ fps: 15 });
|
||||
rendered = 0;
|
||||
for (let i = 1; i <= 120; i++) if (wallpaperFrame(10001 + i * 1000 / 60)) rendered++;
|
||||
assert.ok(rendered >= 29 && rendered <= 31, `Live 15 FPS limit: ${rendered} / 2 s`);
|
||||
function moduleUrl(file) {
|
||||
const { outputText } = ts.transpileModule(readFileSync(file, "utf8"), { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } });
|
||||
return `data:text/javascript;base64,${Buffer.from(outputText).toString("base64")}`;
|
||||
}
|
||||
const qualityModule = moduleUrl("src/render-quality.ts");
|
||||
let controls = ts.transpileModule(readFileSync("src/quality-settings.ts", "utf8"), { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } }).outputText;
|
||||
controls = controls.replace('"./wallpaper"', JSON.stringify(wallpaperModule)).replace('"./render-quality"', JSON.stringify(qualityModule)).replace('"./html"', JSON.stringify(moduleUrl("src/html.ts")));
|
||||
const { qualityMarkup } = await import(`data:text/javascript;base64,${Buffer.from(controls).toString("base64")}`);
|
||||
const { qualityPresets } = await import(qualityModule);
|
||||
const markup = qualityMarkup(qualityPresets.original);
|
||||
assert.ok(!markup.includes("<select"), "Wallpaper must not instantiate CEF native select controls");
|
||||
assert.equal((markup.match(/data-quality-choices=/g) ?? []).length, 8);
|
||||
const selectedLabel = { textContent: "" };
|
||||
const button = { disabled: false, value: "original", dataset: { qualityChoices: JSON.stringify([["original", "原始"], ["high", "高"]]) }, querySelector: () => selectedLabel, dispatchEvent: event => { assert.equal(event.type, "change"); assert.ok(event.bubbles); } };
|
||||
documentListeners.click({ target: { closest: () => button } });
|
||||
assert.equal(button.value, "high");
|
||||
assert.equal(selectedLabel.textContent, "高");
|
||||
documentListeners.click({ target: { closest: () => button } });
|
||||
assert.equal(button.value, "original", "All settings wrap to their first option");
|
||||
const webModule = `data:text/javascript;base64,${Buffer.from(outputText.replace('"wallpaper" === "wallpaper"', '"production" === "wallpaper"')).toString("base64")}`;
|
||||
const webControls = controls.replace(JSON.stringify(wallpaperModule), JSON.stringify(webModule));
|
||||
const { qualityMarkup: webQualityMarkup } = await import(`data:text/javascript;base64,${Buffer.from(webControls).toString("base64")}`);
|
||||
assert.equal((webQualityMarkup(qualityPresets.original).match(/<select/g) ?? []).length, 8, "Website keeps its native dropdowns");
|
||||
console.log("Wallpaper early properties, partial updates, FPS changes, pause/resume and CEF-safe quality controls passed.");
|
||||
@@ -0,0 +1,77 @@
|
||||
import { createRequire } from 'node:module';
|
||||
import { mkdir, writeFile, readFile } from 'node:fs/promises';
|
||||
import assert from 'node:assert/strict';
|
||||
const require = createRequire(import.meta.url);
|
||||
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
|
||||
const browser = await chromium.launch({ channel: 'msedge', headless: true });
|
||||
const context = await browser.newContext({ viewport: { width: 1600, height: 900 } });
|
||||
const page = await context.newPage(), errors = [], results = {};
|
||||
page.on('pageerror', error => errors.push(String(error)));
|
||||
page.on('console', message => { if (message.type() === 'error' && /THREE|shader|WebGL/.test(message.text())) errors.push(message.text()); });
|
||||
const origin = process.env.REVIEW_URL || 'http://127.0.0.1:5188';
|
||||
await mkdir('verification/web-integration', { recursive: true });
|
||||
const stats = () => page.evaluate(() => window.rhine.stats());
|
||||
const close = async () => { await page.locator('[data-action="close-modal"]').click(); await page.waitForTimeout(350); };
|
||||
try {
|
||||
await page.goto(origin);
|
||||
await page.waitForFunction(() => window.rhine?.stats().ready);
|
||||
assert.equal((await stats()).startup, 'waiting');
|
||||
assert.equal(await page.locator('.entry-start').isVisible(), true);
|
||||
await page.locator('.entry-start').click();
|
||||
await page.waitForFunction(() => window.rhine.stats().startup === 'started');
|
||||
await page.evaluate(() => window.rhine.archive());
|
||||
await page.waitForTimeout(1000);
|
||||
assert.equal((await stats()).wallpaper, null);
|
||||
assert.equal(await page.locator('.three-toggle').count(), 0);
|
||||
assert.equal(await page.evaluate(() => typeof window.wallpaperPropertyListener), 'undefined');
|
||||
await page.locator('[data-action="settings"]').click();
|
||||
await page.locator('[data-color-theme="dark"]').click();
|
||||
await page.locator('[data-pref="superPerformance"]').check();
|
||||
await page.waitForTimeout(2000);
|
||||
results.fast = await stats();
|
||||
assert.equal(results.fast.superPerformance, true);
|
||||
assert.equal(results.fast.motion.reduced, false);
|
||||
const savedQuality = await page.evaluate(() => JSON.parse(localStorage.getItem('rhine-settings')).rendering);
|
||||
await close();
|
||||
await page.screenshot({ path: 'verification/web-integration/dark-fast.png' });
|
||||
await page.reload(); await page.waitForFunction(() => window.rhine?.stats().ready);
|
||||
await page.locator('.entry-start').click(); await page.waitForFunction(() => window.rhine.stats().startup === 'started');
|
||||
await page.evaluate(() => window.rhine.archive()); await page.waitForTimeout(800);
|
||||
assert.equal((await stats()).superPerformance, true);
|
||||
assert.equal(await page.evaluate(() => document.documentElement.dataset.darkSurface), 'true');
|
||||
await page.locator('[data-action="settings"]').click();
|
||||
await page.locator('[data-pref="superPerformance"]').uncheck();
|
||||
await close(); await page.waitForTimeout(1000);
|
||||
assert.equal((await stats()).superPerformance, false);
|
||||
assert.deepEqual(await page.evaluate(() => JSON.parse(localStorage.getItem('rhine-settings')).rendering), savedQuality);
|
||||
const before = (await stats()).selected;
|
||||
await page.keyboard.press('ArrowRight'); await page.waitForTimeout(700);
|
||||
assert.notEqual((await stats()).selected, before);
|
||||
const moving = await page.evaluate(async () => {
|
||||
for (let i = 0; i < 30; i++) { if (document.querySelector('#clock').getAnimations({ subtree: true }).some(a => a.playState === 'running')) return true; await new Promise(r => setTimeout(r, 60)); } return false;
|
||||
});
|
||||
assert.equal(moving, true);
|
||||
await page.evaluate(() => window.rhine.detail()); await page.waitForTimeout(2800);
|
||||
assert.equal((await stats()).mode, 'detail');
|
||||
await page.screenshot({ path: 'verification/web-integration/dark-detail.png' });
|
||||
await page.locator('[data-action="model-viewer"]').click(); await page.waitForTimeout(1600);
|
||||
assert.ok(await page.locator('canvas').count() >= 2);
|
||||
await page.keyboard.press('Escape'); await page.waitForTimeout(400);
|
||||
results.viewports = [];
|
||||
for (const [width, height] of [[2560,1080],[390,844],[844,390]]) {
|
||||
await page.setViewportSize({ width, height }); await page.waitForTimeout(600);
|
||||
assert.equal((await stats()).ready, true);
|
||||
const overflow = await page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1);
|
||||
assert.equal(overflow, false);
|
||||
results.viewports.push({ width, height, overflow });
|
||||
}
|
||||
await page.screenshot({ path: 'verification/web-integration/mobile-landscape.png' });
|
||||
const html = await readFile('dist/index.html', 'utf8');
|
||||
assert.match(html, /rel="manifest"/); assert.doesNotMatch(html, /wallpaperPropertyListener/);
|
||||
const worker = await readFile('dist/sw.js', 'utf8'); assert.ok(worker.length > 1000);
|
||||
assert.deepEqual(errors, []);
|
||||
results.startupGesture = results.persistedPerformance = results.restoredQuality = results.numericMotion = results.viewer = results.pwaBuild = true;
|
||||
results.errors = errors;
|
||||
await writeFile('verification/web-integration/results.json', JSON.stringify(results, null, 2));
|
||||
console.log('Web entry, host isolation, theme, performance persistence/restoration, keyboard, clock animation, detail/viewer, responsive and PWA build passed.');
|
||||
} finally { await browser.close(); }
|
||||
@@ -0,0 +1,31 @@
|
||||
import {createServer} from 'node:http';
|
||||
import {readFile,mkdir,writeFile} from 'node:fs/promises';
|
||||
import {resolve,extname} from 'node:path';
|
||||
import {createRequire} from 'node:module';
|
||||
import assert from 'node:assert/strict';
|
||||
const {chromium}=createRequire(import.meta.url)(process.env.PLAYWRIGHT_MODULE||'playwright');
|
||||
const server=createServer(async(req,res)=>{try{let path=new URL(req.url,'http://localhost').pathname;if(path==='/')path='/index.html';let data=await readFile(path==='/wallpaper.jpg'?'reference/extracted-3088099655/wallpaper-2.jpg':resolve('release/wallpaper'+path));if(path.endsWith('.html'))data=Buffer.from(data.toString().replace('</head>',`<script>wallpaperPropertyListener.applyUserProperties({hudparallax:{value:true},huddepth:{value:20},hudtracking:{value:true},load3donstartup:{value:false},boot:{value:false},desktopmode:{value:'workbench'},sound:{value:false},music:{value:false}})</script></head>`));res.setHeader('Content-Type',({'.js':'text/javascript','.css':'text/css','.html':'text/html'})[extname(path)]||'application/octet-stream');res.end(data)}catch{res.statusCode=404;res.end()}});await new Promise(r=>server.listen(5186,'127.0.0.1',r));
|
||||
const browser=await chromium.launch({channel:'msedge',headless:true});
|
||||
try{const page=await browser.newPage({viewport:{width:1600,height:900}}),errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto('http://127.0.0.1:5186/');await page.waitForFunction(()=>window.rhine?.stats().ready);await page.locator('[data-wb-lane="3"]').click();
|
||||
await page.evaluate(()=>{window.rhineWallpaperMedia={properties:{title:'Dive',artist:'Olivia Dean'},timeline:{position:59,duration:203},playing:true};window.dispatchEvent(new Event('rhine-wallpaper-media'))});
|
||||
await page.waitForTimeout(550);await page.evaluate(()=>{window.mediaTitle=document.querySelector('.wb-media h3');window.mediaReel=mediaTitle.firstChild;window.rhineWallpaperMedia.timeline.position=60;window.dispatchEvent(new Event('rhine-wallpaper-media'))});
|
||||
assert.equal(await page.evaluate(()=>mediaTitle===document.querySelector('.wb-media h3')&&mediaReel===mediaTitle.firstChild),true);
|
||||
await page.evaluate(()=>{rhineWallpaperMedia.properties={title:'A New Song',artist:'Another Artist'};window.dispatchEvent(new Event('rhine-wallpaper-media'))});await page.waitForTimeout(550);assert.equal(await page.evaluate(()=>mediaTitle===document.querySelector('.wb-media h3')),true);
|
||||
const mediaMotion=[];for(const title of ['Dive','888999','Wide letters WWW','Thin letters iii','Another Artist - Song']){await page.evaluate(title=>{rhineWallpaperMedia.properties={title,artist:title+' Artist'};window.dispatchEvent(new Event('rhine-wallpaper-media'))},title);await page.waitForTimeout(120);mediaMotion.push({title,active:await page.locator('.wb-media h3').evaluate(el=>el.getAnimations({subtree:true}).filter(a=>a.playState==='running').length)});await page.waitForTimeout(550)}assert.ok(mediaMotion.every(s=>s.active>0));
|
||||
await page.evaluate(()=>{rhineWallpaperMedia.properties={title:'A very long media title that must end with an ellipsis instead of being cut off at the edge of the player',artist:'Artist'};window.dispatchEvent(new Event('rhine-wallpaper-media'))});await page.waitForTimeout(550);assert.ok((await page.locator('.wb-media h3 .rn-value').textContent()).endsWith('…'));assert.ok((await page.locator('.wb-media h3').getAttribute('title')).includes('player'));
|
||||
assert.ok(await page.locator('.wb-clock').evaluate(el=>el.children.length>0));assert.ok(await page.locator('#clock').evaluate(el=>el.children.length>0));const clockMotion=await page.evaluate(async()=>{const samples=[];for(let i=0;i<35;i++){const el=document.querySelector('#clock');samples.push({text:el.getAttribute('aria-label'),running:el.getAnimations({subtree:true}).filter(a=>a.playState==='running').length});await new Promise(r=>setTimeout(r,60))}return samples});assert.ok(clockMotion.some(s=>s.running>0));assert.ok(new Set(clockMotion.map(s=>s.text)).size>1);assert.equal(await page.locator('#clock > .rn-root').count(),3);
|
||||
await page.evaluate(()=>{const NativeDate=window.Date;window.clockNativeDate=NativeDate;window.clockTestValue=new NativeDate(2026,8,11,12,59,59).getTime();window.Date=class extends NativeDate{constructor(...args){super(...(args.length?args:[window.clockTestValue]))}}});
|
||||
await page.waitForFunction(()=>document.querySelector('#clock').getAttribute('aria-label')==='12:59:59');await page.waitForTimeout(500);
|
||||
const digitMotion=[];for(const sec of [0,1,2,3,4,5,6,7,8,9,10]){await page.evaluate(sec=>{window.clockTestValue=new clockNativeDate(2026,8,11,12,59,sec).getTime()},sec);await page.waitForFunction(sec=>document.querySelector('#clock').getAttribute('aria-label')===`12:59:${String(sec).padStart(2,'0')}`,sec);await page.waitForTimeout(120);const state=await page.locator('#clock > span:last-child').evaluate(el=>({active:el.getAnimations({subtree:true}).filter(a=>a.playState==='running').length,reels:[...el.querySelectorAll('.rn-reel')].map(e=>({transform:getComputedStyle(e).transform,faces:e.textContent}))}));digitMotion.push({sec,...state});await page.waitForTimeout(550)}assert.ok(digitMotion.every(s=>s.active>0));
|
||||
await page.evaluate(()=>{window.clockTestValue=new clockNativeDate(2026,8,11,13,0,0).getTime()});await page.waitForFunction(()=>document.querySelector('#clock').getAttribute('aria-label')==='13:00:00'&&document.querySelector('#clock').getAnimations({subtree:true}).some(a=>a.playState==='running'));await page.evaluate(()=>{window.Date=window.clockNativeDate});
|
||||
await page.evaluate(()=>{wallpaperPropertyListener.applyUserProperties({reduced:{value:true}});rhineWallpaperMedia.properties={title:'Final Song',artist:'Final Artist'};window.dispatchEvent(new Event('rhine-wallpaper-media'))});
|
||||
await mkdir('verification/workbench-rolling',{recursive:true});await page.evaluate(()=>wallpaperPropertyListener.applyUserProperties({customwallpaper:{value:true},customwallpaperfile:{value:'http://127.0.0.1:5186/wallpaper.jpg'},customwallpapermask:{value:0},uifrost:{value:true},colortheme:{value:'light'}}));await page.waitForFunction(()=>document.querySelector('#stage').dataset.customWallpaperVisible==='true');await page.waitForTimeout(800);await page.screenshot({path:'verification/workbench-rolling/media.png'});
|
||||
await page.locator('[data-wb-lane="4"]').click();assert.ok(await page.locator('.wb-timer-digits').count());await page.locator('[data-wb-timer="toggle"]').click();await page.waitForTimeout(1200);assert.ok(await page.locator('.wb-timer-digits').evaluate(el=>el.children.length>0));assert.deepEqual(errors,[]);await writeFile('verification/workbench-rolling/results.json',JSON.stringify({retainedMediaNode:true,retainedUnchangedReel:true,clockReels:true,longTitleEllipsis:true,digitMotion,mediaMotion,footerNumericReels:3,footerRunningAnimation:true,footerHourCarry:true,focusReels:true,reducedMotion:true,errors},null,2));console.log('Clock, media identity/progress/track changes, focus timer and reduced motion passed.');
|
||||
}finally{await browser.close();server.close()}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import ts from 'typescript';
|
||||
import vm from 'node:vm';
|
||||
const source = ts.transpileModule(readFileSync('src/workbench-state.ts', 'utf8'), {compilerOptions:{module:ts.ModuleKind.ESNext,target:ts.ScriptTarget.ES2022}}).outputText;
|
||||
const {parseTarget, restoreTimer, timerLeft, durationText, dayKey} = await import(`data:text/javascript;base64,${Buffer.from(source).toString('base64')}`);
|
||||
assert.equal(parseTarget('2026-02-30'), null);
|
||||
assert.equal(parseTarget('2026-09-10 24:00'), null);
|
||||
assert.equal(parseTarget('invalid'), null);
|
||||
assert.equal(parseTarget('2026-09-10'), new Date(2026,8,10).getTime());
|
||||
assert.equal(parseTarget('2028-02-29 12:30'), new Date(2028,1,29,12,30).getTime());
|
||||
assert.equal(dayKey(new Date(2026,0,2,0,1)), '2026-01-02');
|
||||
const running={phase:'focus',status:'running',remaining:60000,deadline:100000};
|
||||
assert.equal(timerLeft(restoreTimer(JSON.parse(JSON.stringify(running))), 85000),15000,'Reload resumes by absolute deadline');
|
||||
assert.equal(timerLeft(running,1000000),0,'Wallpaper suspension cannot extend the timer');
|
||||
assert.equal(timerLeft({...running,status:'paused',remaining:12000},1000000),12000,'Paused timer does not elapse');
|
||||
assert.equal(restoreTimer({...running,remaining:NaN}).status,'idle');
|
||||
assert.equal(restoreTimer({phase:'wrong'}).status,'idle');
|
||||
assert.equal(durationText(1),'00:01');
|
||||
const listeners={};const window={dispatchEvent(){},wallpaperMediaIntegration:{PLAYBACK_PLAYING:7}};
|
||||
for (const kind of ['Status','Properties','Thumbnail','Playback','Timeline']) window[`wallpaperRegisterMedia${kind}Listener`] = callback => listeners[kind]=callback;
|
||||
vm.runInNewContext(readFileSync('wallpaper/host.js','utf8'),{window,Event,CustomEvent});
|
||||
listeners.Properties({title:'A'}); listeners.Timeline({position:12,duration:100});listeners.Thumbnail({thumbnail:'old'});listeners.Playback({state:7});
|
||||
assert.equal(window.rhineWallpaperMedia.playing,true);
|
||||
listeners.Properties({title:'B'});
|
||||
assert.equal(window.rhineWallpaperMedia.properties.title,'B');
|
||||
assert.equal(window.rhineWallpaperMedia.timeline.position,12,'Text updates cannot discard the independent timeline channel');
|
||||
assert.equal(window.rhineWallpaperMedia.thumbnail.thumbnail,'old','Same artwork may be reused across tracks without another callback');
|
||||
// Local player: artwork arrives first, then text, then a late metadata refresh.
|
||||
listeners.Thumbnail({thumbnail:'magic-theorem-cover'});
|
||||
listeners.Timeline({position:0,duration:242});
|
||||
listeners.Properties({title:'Magic Theorem',artist:'塞壬唱片-MSR/Adam Gubman/Sarah Kang'});
|
||||
listeners.Playback({state:7});
|
||||
listeners.Properties({title:'Magic Theorem',artist:'塞壬唱片-MSR/Adam Gubman/Sarah Kang',albumTitle:'Magic Theorem'});
|
||||
assert.equal(window.rhineWallpaperMedia.thumbnail.thumbnail,'magic-theorem-cover','Late text cannot erase a local-file cover');
|
||||
assert.equal(window.rhineWallpaperMedia.timeline.duration,242);
|
||||
// Reverse callback order, pause/resume and switching between players.
|
||||
listeners.Playback({state:2});listeners.Properties({title:'Cloud track'});listeners.Thumbnail({thumbnail:'cloud-cover'});
|
||||
assert.equal(window.rhineWallpaperMedia.thumbnail.thumbnail,'cloud-cover');
|
||||
listeners.Thumbnail({thumbnail:'magic-theorem-cover'});listeners.Properties({title:'Magic Theorem'});listeners.Playback({state:7});
|
||||
assert.equal(window.rhineWallpaperMedia.thumbnail.thumbnail,'magic-theorem-cover');
|
||||
// Explicit empty events still remove unavailable artwork/progress.
|
||||
listeners.Thumbnail({thumbnail:''});listeners.Timeline({position:0,duration:0});listeners.Properties({title:'No artwork'});
|
||||
assert.equal(window.rhineWallpaperMedia.thumbnail.thumbnail,'');
|
||||
assert.equal(window.rhineWallpaperMedia.timeline.duration,0);
|
||||
console.log('Workbench dates, timer suspension/reload, corrupt storage and early media callbacks passed.');
|
||||
const visibilitySource = ts.transpileModule(readFileSync('src/workbench-visibility.ts', 'utf8'), {compilerOptions:{module:ts.ModuleKind.ESNext,target:ts.ScriptTarget.ES2022}}).outputText;
|
||||
const {defaultWorkbenchVisibility, applyVisibilityProperties, workbenchElements} = await import(`data:text/javascript;base64,${Buffer.from(visibilitySource).toString('base64')}`);
|
||||
const hiddenProps = Object.fromEntries(workbenchElements.map(([key]) => [`show${key}`, {value:false}]));
|
||||
const quiet = applyVisibilityProperties(defaultWorkbenchVisibility(), hiddenProps);
|
||||
assert.ok(Object.values(quiet).every(value => value === false), 'WE can hide every element including settings');
|
||||
const clockOnly = applyVisibilityProperties(quiet, {showclock:{value:true}});
|
||||
assert.equal(clockOnly.clock, true);
|
||||
assert.equal(clockOnly.settings, false, 'Partial host changes preserve hidden settings');
|
||||
assert.deepEqual(applyVisibilityProperties(clockOnly,{sound:{value:true},showtasks:{value:'false'}}),clockOnly,'Unrelated/invalid properties cannot reset visibility');
|
||||
assert.deepEqual(applyVisibilityProperties(defaultWorkbenchVisibility(),hiddenProps),quiet,'Startup host properties reproduce the saved composition');
|
||||
console.log('Workbench host visibility: all hidden, clock-only, partial updates and startup restoration passed.');
|
||||
@@ -0,0 +1,79 @@
|
||||
// Unpack npm [email protected] and [email protected] under .tools/font-comparison first.
|
||||
// This reads package files; it never executes upstream scripts or modifies glyphs.
|
||||
import { readFile, writeFile, stat, mkdir } from 'node:fs/promises';
|
||||
import { resolve, dirname, extname, sep } from 'node:path';
|
||||
import { createServer } from 'node:http';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
const { chromium } = await import(process.env.PLAYWRIGHT_MODULE ? pathToFileURL(resolve(process.env.PLAYWRIGHT_MODULE)).href : 'playwright');
|
||||
const root = resolve('.'), output = resolve('.tools/font-comparison');
|
||||
const weights = [[300,'Light'],[400,'Regular'],[600,'Demibold'],[700,'Bold']];
|
||||
const packages = ['original','dsrkafuu','mobeicanyue'];
|
||||
const content = JSON.parse(await readFile('content/archives.json','utf8'));
|
||||
const strings = value => typeof value === 'string' ? [value] : value && typeof value === 'object' ? Object.values(value).flatMap(strings) : [];
|
||||
const opening = 'RHINE LAB SYNTHESIZE INFORMATION ANALYSIS OS ACCESS JOYCE MOORE WELCOME TO INTERNAL DATABASE PERMISSION AUTHORIZED 身份信息确认请求已接收开始处理权限验证通过欢迎访问莱茵生命内部资料档案编号保密级别商业区选择档案0123456789:,。·+-/';
|
||||
const corpus = [...new Set([...strings(content).join('') + opening])].join('');
|
||||
const scenarios = {opening, archives:corpus};
|
||||
let css = '';
|
||||
const report = { packages: {}, visual: [] };
|
||||
for (const name of packages) {
|
||||
report.packages[name] = { scenarios:{}, allFontBytes:0, allFiles:0 };
|
||||
for (const [weight,label] of weights) {
|
||||
const source = name === 'original' ? '' : resolve(output, name, 'package', name === 'dsrkafuu' ? `lib/Normal/MiSans-${label}.min.css` : `misans/misans-${label.toLowerCase()}/result.css`);
|
||||
const originalDirectory = process.env.BASELINE_FONT_DIR || '.tools/issues-before/fonts';
|
||||
const faces = name === 'original' ? [{file:resolve(originalDirectory,`MiSans-${label}.woff2`),range:'U+0-10FFFF'}] : [...(await readFile(source,'utf8')).matchAll(/@font-face\s*\{([^}]+)\}/g)].map(([,body])=>({
|
||||
file:resolve(dirname(source),body.match(/url\(['"]?([^'"\)]+)['"]?\)/)[1]),
|
||||
range:body.match(/unicode-range:([^;}]*)/i)[1],
|
||||
}));
|
||||
for (const face of faces) {
|
||||
face.bytes = (await stat(face.file)).size;
|
||||
face.ranges = face.range.split(',').map(r=>r.trim().replace(/^U\+/i,'').split('-').map(x=>parseInt(x,16)));
|
||||
css += `@font-face{font-family:${name};font-weight:${weight};font-display:swap;src:url('/${face.file.slice(root.length+1).replaceAll('\\','/')}');unicode-range:${face.range}}\n`;
|
||||
report.packages[name].allFontBytes += face.bytes;
|
||||
report.packages[name].allFiles++;
|
||||
}
|
||||
for (const [scenario,text] of Object.entries(scenarios)) {
|
||||
const points = [...text].map(c=>c.codePointAt(0));
|
||||
const hit = faces.filter(face=>points.some(p=>face.ranges.some(([a,b=a])=>p>=a && p<=b)));
|
||||
const missing = [...new Set([...text].filter(c=>!faces.some(face=>face.ranges.some(([a,b=a])=>c.codePointAt(0)>=a && c.codePointAt(0)<=b))))];
|
||||
report.packages[name].scenarios[`${scenario}-${weight}`] = {bytes:hit.reduce((n,f)=>n+f.bytes,0),files:hit.length,missing};
|
||||
}
|
||||
}
|
||||
}
|
||||
await mkdir(output,{recursive:true});
|
||||
await writeFile(resolve(output,'compare.css'),css);
|
||||
const sample = ['RHINE LAB','SYNTHESIZE INFORMATION','ANALYSIS OS / X-001','莱茵生命 · 内部资料档案','身份信息确认:JOYCE MOORE','克丽斯腾/赫默/塞雷娅/缪尔赛思'];
|
||||
const html = `<!doctype html><meta charset="utf-8"><link rel="stylesheet" href="/ .tools/font-comparison/compare.css"><style>body{background:#e8e5e1;color:#171713;display:flex;gap:32px;padding:32px;margin:0}section{flex:1;min-width:0}h2{font:16px system-ui}p{margin:18px 0;white-space:nowrap}</style>${packages.map(name=>`<section><h2>${name}</h2>${weights.map(([weight])=>sample.map(text=>`<p style="font: ${weight} 22px ${name}">${text}</p>`).join('')).join('')}</section>`).join('')}`.replace('/ .tools','/.tools');
|
||||
const server = createServer(async(req,res)=>{try {
|
||||
const pathname = decodeURIComponent(new URL(req.url,'http://localhost').pathname);
|
||||
if(pathname==='/'){res.writeHead(200,{'Content-Type':'text/html'}).end(html);return}
|
||||
const file = resolve(root,'.'+pathname);if(!file.startsWith(root+sep))throw Error('path');
|
||||
const body = await readFile(file);
|
||||
res.writeHead(200,{'Content-Type':extname(file)==='.css'?'text/css':extname(file)==='.woff2'?'font/woff2':'application/octet-stream'}).end(body);
|
||||
}catch{res.writeHead(404).end()}});
|
||||
await new Promise(r=>server.listen(5195,'127.0.0.1',r));
|
||||
const browser = await chromium.launch({channel:'chrome',headless:true});
|
||||
try {
|
||||
const page = await browser.newPage({viewport:{width:1920,height:1280}});
|
||||
await page.goto('http://127.0.0.1:5195/');
|
||||
report.visual = await page.evaluate(async({packages,weights,corpus,sample})=>{
|
||||
await Promise.all(packages.flatMap(name=>weights.map(([w])=>document.fonts.load(`${w} 24px ${name}`,corpus))));
|
||||
const render = (family,weight) => {
|
||||
const canvas=document.createElement('canvas');canvas.width=1200;canvas.height=100+Math.ceil([...corpus].length/45)*32;
|
||||
const c=canvas.getContext('2d');c.fillStyle='#fff';c.fillRect(0,0,canvas.width,canvas.height);c.fillStyle='#111';c.font=`${weight} 24px ${family}`;
|
||||
c.fillText(sample.slice(0,3).join(' '),0,30);
|
||||
[...corpus].forEach((char,i)=>c.fillText(char,(i%45)*26,70+Math.floor(i/45)*32));
|
||||
return {data:c.getImageData(0,0,canvas.width,canvas.height).data,widths:sample.map(s=>c.measureText(s).width)};
|
||||
};
|
||||
return weights.flatMap(([weight])=>{
|
||||
const original=render('original',weight);
|
||||
return packages.slice(1).map(name=>{
|
||||
const candidate=render(name,weight);let sum=0,changed=0;
|
||||
for(let i=0;i<original.data.length;i++){const d=Math.abs(original.data[i]-candidate.data[i]);sum+=d;if(d)changed++}
|
||||
return {name,weight,meanChannelDifference:sum/original.data.length,changedChannels:changed,widthDifferences:candidate.widths.map((w,i)=>w-original.widths[i])};
|
||||
});
|
||||
});
|
||||
},{packages,weights,corpus,sample});
|
||||
await page.screenshot({path:resolve(output,'comparison.png'),fullPage:true});
|
||||
} finally { await browser.close();await new Promise(r=>server.close(r)); }
|
||||
await writeFile(resolve(output,'report.json'),JSON.stringify(report,null,2));
|
||||
console.log(JSON.stringify(report,null,2));
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Cut actual workbench footage into a square overview/detail GIF for Workshop."""
|
||||
import bisect
|
||||
import json
|
||||
from pathlib import Path
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
root = Path('reference/workshop-preview')
|
||||
frames = json.loads((root / 'frames.json').read_text('utf-8'))
|
||||
images = [Image.open(root / f['file']).convert('RGB') for f in frames]
|
||||
times = [f['time'] for f in frames]
|
||||
# duration, source start/end ms, crop center x/y, crop size start/end.
|
||||
# Full views retain the whole widescreen composition with black letterboxing.
|
||||
shots = [
|
||||
(1800, 2507, 4500, 640, 360, 1280, 1280),
|
||||
(2400, 4500, 5900, 240, 307, 465, 435),
|
||||
(2400, 7000, 10200, 1040, 338, 465, 435),
|
||||
(2000, 12000, 14300, 640, 360, 1280, 1280),
|
||||
(2000, 14300, 16200, 1040, 338, 465, 435),
|
||||
(2600, 18800, 22269, 640, 360, 1280, 1280),
|
||||
]
|
||||
output = []
|
||||
contact = Image.new('RGB', (960, 696), '#242424')
|
||||
draw = ImageDraw.Draw(contact)
|
||||
for shot_index, (duration, begin, end, cx, cy, initial, final) in enumerate(shots):
|
||||
for t in range(0, duration, 100):
|
||||
progress = t / duration
|
||||
source_time = begin + (end - begin) * progress
|
||||
index = max(0, min(len(images) - 1, bisect.bisect_right(times, source_time) - 1))
|
||||
ease = progress * progress * (3 - 2 * progress)
|
||||
size = initial + (final - initial) * ease
|
||||
crop = images[index].crop((round(cx - size / 2), round(cy - size / 2), round(cx + size / 2), round(cy + size / 2)))
|
||||
output.append(crop.resize((640, 640), Image.Resampling.LANCZOS))
|
||||
if t == 0:
|
||||
x, y = shot_index % 3 * 320, shot_index // 3 * 348
|
||||
contact.paste(output[-1].resize((320, 320), Image.Resampling.LANCZOS), (x, y))
|
||||
draw.text((x + 8, y + 326), f'Shot {shot_index + 1} / {duration / 1000:.1f}s', fill='white')
|
||||
contact.save(root / 'storyboard.jpg')
|
||||
|
||||
for filename, size in [('wallpaper/preview.gif', (256, 256)), ('reference/workshop-preview/workbench-preview.gif', (640, 640))]:
|
||||
resized = [image.resize(size, Image.Resampling.LANCZOS) for image in output]
|
||||
sample = Image.new('RGB', (size[0], size[1] * 12))
|
||||
for i in range(12):
|
||||
sample.paste(resized[min(len(resized) - 1, i * len(resized) // 12)], (0, i * size[1]))
|
||||
palette = sample.quantize(colors=24 if size[0] == 256 else 96)
|
||||
indexed = [image.quantize(palette=palette, dither=Image.Dither.NONE) for image in resized]
|
||||
indexed[0].save(filename, save_all=True, append_images=indexed[1:], duration=100, loop=0, optimize=True, disposal=1)
|
||||
file = Path(filename)
|
||||
check = Image.open(file)
|
||||
print(filename, file.stat().st_size, 'bytes', check.n_frames, 'encoded frames', len(output) * 100, 'ms')
|
||||
if filename == 'wallpaper/preview.gif':
|
||||
assert file.stat().st_size < 1_000_000
|
||||
assert check.n_frames > 1 and check.size == (256, 256)
|
||||
@@ -0,0 +1,15 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { loadContent, archiveText } from "./archive-content.mjs";
|
||||
|
||||
// Validate the entire input before writing any downloads.
|
||||
const { records } = await loadContent();
|
||||
const output = new URL("../public/archives/", import.meta.url);
|
||||
await fs.mkdir(output, { recursive: true });
|
||||
for (const record of records) {
|
||||
await fs.writeFile(
|
||||
new URL(`RHINE-LAB-${record.id}.txt`, output),
|
||||
archiveText(record),
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
console.log(`Prepared ${records.length} downloadable archive records.`);
|
||||
@@ -0,0 +1,146 @@
|
||||
// Reproduce typing clicks from the user's local reference video.
|
||||
// node scripts/extract-typing-audio.mjs path/to/ffmpeg.exe
|
||||
// These short excerpts retain the source recording's provenance, not the score's license.
|
||||
import fs from "node:fs";
|
||||
import crypto from "node:crypto";
|
||||
import { execFileSync } from "node:child_process";
|
||||
const ffmpeg = process.argv[2],
|
||||
video = fs.readdirSync(".").find((x) => x.endsWith(".mp4"));
|
||||
if (!ffmpeg || !video)
|
||||
throw Error("A local reference MP4 and FFmpeg path are required");
|
||||
const rate = 48000,
|
||||
start = 6.84,
|
||||
seconds = 0.55;
|
||||
const bytes = execFileSync(
|
||||
ffmpeg,
|
||||
[
|
||||
"-v",
|
||||
"error",
|
||||
"-ss",
|
||||
String(start),
|
||||
"-i",
|
||||
video,
|
||||
"-t",
|
||||
String(seconds),
|
||||
"-vn",
|
||||
"-af",
|
||||
"pan=mono|c0=0.5*c0+0.5*c1",
|
||||
"-ar",
|
||||
String(rate),
|
||||
"-f",
|
||||
"f32le",
|
||||
"pipe:1",
|
||||
],
|
||||
{ maxBuffer: 1024 * 1024 },
|
||||
);
|
||||
const source = new Float32Array(
|
||||
bytes.buffer,
|
||||
bytes.byteOffset,
|
||||
bytes.length / 4,
|
||||
);
|
||||
const cuts = [6.864, 6.906, 6.948],
|
||||
duration = 0.038;
|
||||
const samples = cuts.map((time) =>
|
||||
source.slice(
|
||||
Math.round((time - start) * rate),
|
||||
Math.round((time - start + duration) * rate),
|
||||
),
|
||||
);
|
||||
let peak = 0;
|
||||
for (const sample of samples) {
|
||||
const dc = sample.reduce((sum, x) => sum + x, 0) / sample.length;
|
||||
for (let i = 0; i < sample.length; i++) {
|
||||
const fadeIn = Math.min(1, i / (rate * 0.00035)),
|
||||
fadeOut = Math.min(1, (sample.length - 1 - i) / (rate * 0.0035));
|
||||
sample[i] = (sample[i] - dc) * fadeIn * fadeOut;
|
||||
peak = Math.max(peak, Math.abs(sample[i]));
|
||||
}
|
||||
}
|
||||
// Shared gain preserves the three original clicks' relative accents.
|
||||
const gain = 0.3 / peak;
|
||||
const pcm = samples.map((sample) => {
|
||||
const out = Buffer.alloc(sample.length * 2);
|
||||
for (let i = 0; i < sample.length; i++)
|
||||
out.writeInt16LE(Math.round(sample[i] * gain * 32767), i * 2);
|
||||
return out;
|
||||
});
|
||||
fs.mkdirSync("public/audio", { recursive: true });
|
||||
function wav(data, file) {
|
||||
const out = Buffer.alloc(44 + data.length);
|
||||
out.write("RIFF");
|
||||
out.writeUInt32LE(out.length - 8, 4);
|
||||
out.write("WAVEfmt ", 8);
|
||||
out.writeUInt32LE(16, 16);
|
||||
out.writeUInt16LE(1, 20);
|
||||
out.writeUInt16LE(1, 22);
|
||||
out.writeUInt32LE(rate, 24);
|
||||
out.writeUInt32LE(rate * 2, 28);
|
||||
out.writeUInt16LE(2, 32);
|
||||
out.writeUInt16LE(16, 34);
|
||||
out.write("data", 36);
|
||||
out.writeUInt32LE(data.length, 40);
|
||||
data.copy(out, 44);
|
||||
fs.writeFileSync(file, out);
|
||||
}
|
||||
// Review-only source excerpt. Uniform gain makes its level comparable to the rebuilt sequence.
|
||||
const referencePcm = Buffer.alloc(source.length * 2);
|
||||
for (let i = 0; i < source.length; i++)
|
||||
referencePcm.writeInt16LE(
|
||||
Math.round(Math.max(-1, Math.min(1, source[i] * gain * 0.2)) * 32767),
|
||||
i * 2,
|
||||
);
|
||||
wav(referencePcm, "reference/typing-original.wav");
|
||||
const preview = Buffer.alloc(rate * 2 * 2);
|
||||
const pulseTimes = [
|
||||
0.02, 0.064, 0.108, 0.152, 0.2, 0.244, 0.288, 0.332, 0.38, 0.424, 0.468,
|
||||
0.516, 1.02, 1.068, 1.116, 1.16, 1.208, 1.256, 1.304, 1.352,
|
||||
];
|
||||
pulseTimes.forEach((t, n) => {
|
||||
const click = pcm[n % pcm.length],
|
||||
offset = Math.round(t * rate);
|
||||
for (let i = 0; i < click.length / 2; i++)
|
||||
preview.writeInt16LE(
|
||||
Math.round(click.readInt16LE(i * 2) * 0.2),
|
||||
(offset + i) * 2,
|
||||
);
|
||||
});
|
||||
wav(preview, "public/audio/typing-preview.wav");
|
||||
fs.writeFileSync(
|
||||
"src/typing-samples.ts",
|
||||
`// Generated by scripts/extract-typing-audio.mjs. Source provenance: public/audio/typing-source.json.\nexport const TYPING_SAMPLE_RATE = ${rate};\nexport const TYPING_PCM = ${JSON.stringify(
|
||||
pcm.map((x) => x.toString("base64")),
|
||||
null,
|
||||
2,
|
||||
)} as const;\n`,
|
||||
);
|
||||
const report = {
|
||||
source: video,
|
||||
sourceSha256: crypto
|
||||
.createHash("sha256")
|
||||
.update(fs.readFileSync(video))
|
||||
.digest("hex"),
|
||||
sampleRate: rate,
|
||||
channels: 1,
|
||||
cuts: cuts.map((start) => ({ start, end: +(start + duration).toFixed(3) })),
|
||||
processing: {
|
||||
dcRemoval: true,
|
||||
fadeInMs: 0.35,
|
||||
fadeOutMs: 3.5,
|
||||
sharedGain: gain,
|
||||
denoising: false,
|
||||
pitchChange: false,
|
||||
},
|
||||
scope:
|
||||
"Three short excerpts from user-provided reference. Not original synthesis; source rights remain with the original creators.",
|
||||
};
|
||||
fs.writeFileSync(
|
||||
"public/audio/typing-source.json",
|
||||
JSON.stringify(report, null, 2) + "\n",
|
||||
);
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{ cuts: report.cuts, bytes: pcm.reduce((n, b) => n + b.length, 0), gain },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Export only the fixed opening phrases as outlined graphic artwork.
|
||||
|
||||
Requires Pillow and fonttools==4.59.2. Supply locally licensed OTFs; neither
|
||||
the fonts nor a reusable character/font table are included in the output.
|
||||
Example:
|
||||
python scripts/make-boot-lettering.py --fonts .tools/font-comparison/fonts
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / '.tools/font-comparison/python'))
|
||||
from fontTools.ttLib import TTFont
|
||||
from fontTools.pens.svgPathPen import SVGPathPen
|
||||
from fontTools.pens.transformPen import TransformPen
|
||||
from PIL import ImageFont
|
||||
|
||||
PHRASES = [
|
||||
('brand', 'RHINE LAB', 'DemiBold'),
|
||||
('access', 'ACCESS PERMISSION REQUIRED', 'Normal'),
|
||||
('identity', 'ID CONFIRMED : JOYCE MOORE', 'Normal'),
|
||||
('request', 'REQUEST RECEIVED', 'Normal'),
|
||||
('processing', 'START PROCESSING...', 'Normal'),
|
||||
('processingGlitch', ' SING...', 'Normal'),
|
||||
('permission', 'PERMISSION AUTHORIZED', 'Normal'),
|
||||
('welcome', 'WELCOME TO', 'Bold'),
|
||||
('company', 'RHINE LAB.LLC.', 'Bold'),
|
||||
('database', 'INTERNAL DATABASE', 'Bold'),
|
||||
]
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--fonts', required=True, type=Path)
|
||||
args = parser.parse_args()
|
||||
art, sources = {}, {}
|
||||
for weight in ('Normal', 'DemiBold', 'Bold'):
|
||||
path = args.fonts / f'Novecentosanswide-{weight}.otf'
|
||||
font = TTFont(path)
|
||||
units = font['head'].unitsPerEm
|
||||
glyphs, cmap = font.getGlyphSet(), font.getBestCmap()
|
||||
layout = ImageFont.truetype(str(path), units)
|
||||
sources[weight] = {
|
||||
'filename': path.name,
|
||||
'sha256': hashlib.sha256(path.read_bytes()).hexdigest(),
|
||||
'version': font['name'].getDebugName(5),
|
||||
}
|
||||
for key, text, phrase_weight in PHRASES:
|
||||
if weight != phrase_weight:
|
||||
continue
|
||||
letters = []
|
||||
for i, char in enumerate(text):
|
||||
pen = SVGPathPen(glyphs)
|
||||
# A fixed graphic cell, with its baseline 0.8 em from the top.
|
||||
glyphs[cmap[ord(char)]].draw(TransformPen(pen, (1, 0, 0, -1, 0, units * .8)))
|
||||
advance = layout.getlength(char)
|
||||
if i + 1 < len(text):
|
||||
advance += layout.getlength(text[i:i+2]) - layout.getlength(char) - layout.getlength(text[i+1])
|
||||
letters.append({'width': round(advance / units, 6), 'path': pen.getCommands()})
|
||||
art[key] = {'text': text, 'weight': weight, 'units': units, 'letters': letters}
|
||||
font.close()
|
||||
target = ROOT / 'src/boot-lettering-art.json'
|
||||
target.write_text(json.dumps(art, ensure_ascii=False, separators=(',', ':')) + '\n', encoding='utf-8')
|
||||
(ROOT / 'verification/boot-lettering').mkdir(parents=True, exist_ok=True)
|
||||
(ROOT / 'verification/boot-lettering/sources.json').write_text(json.dumps(sources, indent=2) + '\n', encoding='utf-8')
|
||||
print(f'{len(art)} fixed phrases: {target.stat().st_size} bytes. No font files emitted.')
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,21 @@
|
||||
import { cp, mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
// Static Build Output API packaging does not require downloading production
|
||||
// environment variables. Run npm run build first, then vercel deploy --prebuilt.
|
||||
const metadata = JSON.parse(await readFile('dist/pwa-build.json', 'utf8'));
|
||||
if (!metadata.files.some(path => path.includes('NovecentoSansWideNormal/font.woff2')))
|
||||
throw new Error('Install the locally licensed Novecento kit before packaging the official deployment.');
|
||||
await mkdir('.vercel/output', { recursive: true });
|
||||
await cp(resolve('dist'), resolve('.vercel/output/static'), { recursive: true });
|
||||
await writeFile('.vercel/output/config.json', JSON.stringify({
|
||||
version: 3,
|
||||
routes: [
|
||||
{ src: '^/fonts/misans-webfont-4.3.1/(.*)$', headers: { 'Cache-Control': 'public, max-age=31536000, immutable' }, continue: true },
|
||||
{ src: '^/assets/(archive-(?:cassette|assembly)\\.[a-f0-9]{16}\\.glb)$', headers: { 'Cache-Control': 'public, max-age=31536000, immutable' }, continue: true },
|
||||
{ src: '^/update(.*)$', headers: { 'Cache-Control': 'no-cache, no-store, must-revalidate' }, continue: true },
|
||||
{ src: '^/(sw\\.js|manifest\\.webmanifest|pwa-build\\.json)$', headers: { 'Cache-Control': 'no-cache, must-revalidate' }, continue: true },
|
||||
{ handle: 'filesystem' },
|
||||
],
|
||||
}, null, 2));
|
||||
console.log(`Packaged official static deployment ${metadata.version}.`);
|
||||
@@ -0,0 +1,7 @@
|
||||
import {readFile,writeFile,readdir} from 'node:fs/promises';
|
||||
const dir='node_modules/@kitlangton/rolling-number/dist';
|
||||
const before='S={width:M.width/s,height:M.height/o};this.sizes.set(y,S)';
|
||||
const after='S={width:parseFloat(t.getComputedStyle(y).width),height:parseFloat(t.getComputedStyle(y).height)};this.sizes.set(y,S)';
|
||||
let found=false;
|
||||
for(const file of await readdir(dir))if(file.endsWith('.js')){const path=`${dir}/${file}`,source=await readFile(path,'utf8');if(source.includes(after)){found=true;continue}if(source.includes(before)){await writeFile(path,source.replace(before,after));found=true;console.log('Patched rolling-number local glyph measurement for projected HUD.')}}
|
||||
if(!found)throw Error('Rolling Number measurement patch no longer matches. Review the dependency before upgrading.');
|
||||
@@ -0,0 +1,35 @@
|
||||
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
|
||||
// This is the license owner's existing Vercel project, not a font source for
|
||||
// third-party forks. Other checkouts retain the authored phrase artwork.
|
||||
const officialBuild = process.env.VERCEL_PROJECT_ID === 'prj_KyOQlIfl3qhHkI4SUpiD5tbFTE5w';
|
||||
const sources = JSON.parse(await readFile(new URL('../verification/boot-lettering/webfont-sources.json', import.meta.url), 'utf8'));
|
||||
const files = [
|
||||
...Object.entries(sources).map(([weight, source]) => ({
|
||||
path: `webFonts/NovecentoSansWide${weight}/font.woff2`, hash: source.sha256,
|
||||
})),
|
||||
{ path: 'RhineLabNovecento.css', hash: '9495a310fe80cc0c06c56cb9e04926ae4135e41ce674bacb6cd38c0d5f4f0f7a' },
|
||||
];
|
||||
const digest = bytes => createHash('sha256').update(bytes).digest('hex');
|
||||
let restored = 0;
|
||||
for (const file of files) {
|
||||
const target = resolve('public/fonts/novecento', file.path);
|
||||
let bytes;
|
||||
try { bytes = await readFile(target); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
||||
if (!bytes && officialBuild) {
|
||||
// Bootstrap once from the owner's local kit. Later Git builds preserve
|
||||
// those same licensed bytes from the currently active production site.
|
||||
const url = `https://rhine.lubeiluchen.cc/fonts/novecento/${file.path}`;
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(15000) });
|
||||
if (!response.ok) throw new Error(`Licensed font restore failed (${response.status}): ${file.path}`);
|
||||
bytes = Buffer.from(await response.arrayBuffer());
|
||||
if (digest(bytes) !== file.hash) throw new Error(`Licensed font checksum mismatch: ${file.path}`);
|
||||
await mkdir(dirname(target), { recursive: true });
|
||||
await writeFile(target, bytes);
|
||||
restored++;
|
||||
}
|
||||
if (bytes && digest(bytes) !== file.hash) throw new Error(`Local licensed font checksum mismatch: ${file.path}`);
|
||||
}
|
||||
if (restored) console.log(`Restored ${restored} verified licensed assets for the owner's website build.`);
|
||||
@@ -0,0 +1,61 @@
|
||||
/* The build replaces both tokens; this file is never registered in development. */
|
||||
const VERSION = __CACHE_VERSION__;
|
||||
const FILES = __PRECACHE_FILES__;
|
||||
const PREFIX = `rhine-lab:${new URL(self.registration.scope).pathname}:`;
|
||||
const CACHE = PREFIX + VERSION;
|
||||
const urls = FILES.map(path => new URL(path, self.registration.scope).href);
|
||||
const allowed = new Set(urls);
|
||||
const index = new URL("index.html", self.registration.scope).href;
|
||||
|
||||
self.addEventListener("install", event => {
|
||||
event.waitUntil((async () => {
|
||||
try {
|
||||
const cache = await caches.open(CACHE);
|
||||
// Limit connections so a complete font family does not flood the page.
|
||||
// Keep successful files private until every resource is present; failure
|
||||
// still deletes this entire release and leaves the active release intact.
|
||||
let next = 0;
|
||||
const workers = Array.from({ length: 6 }, async () => {
|
||||
while (next < urls.length) {
|
||||
const url = urls[next++];
|
||||
const immutable = /\/fonts\/misans-webfont-4\.3\.1\//.test(url) || /\/assets\/archive-(cassette|assembly)\.[a-f0-9]{16}\.glb$/.test(url);
|
||||
await cache.add(new Request(url, { cache: immutable ? "default" : "no-cache" }));
|
||||
}
|
||||
});
|
||||
const results = await Promise.allSettled(workers);
|
||||
const failure = results.find(result => result.status === "rejected");
|
||||
if (failure) throw failure.reason;
|
||||
} catch (error) {
|
||||
await caches.delete(CACHE);
|
||||
throw error;
|
||||
}
|
||||
})());
|
||||
});
|
||||
self.addEventListener("activate", event => {
|
||||
event.waitUntil((async () => {
|
||||
for (const key of await caches.keys())
|
||||
if (key.startsWith(PREFIX) && key !== CACHE) await caches.delete(key);
|
||||
await self.clients.claim();
|
||||
})());
|
||||
});
|
||||
self.addEventListener("message", event => {
|
||||
if (event.data?.type === "RHINE_APPLY_UPDATE") event.waitUntil(self.skipWaiting());
|
||||
});
|
||||
self.addEventListener("fetch", event => {
|
||||
if (event.request.method !== "GET") return;
|
||||
const url = new URL(event.request.url);
|
||||
if (url.origin !== self.location.origin) return;
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
const navigation = event.request.mode === "navigate" &&
|
||||
(url.href === self.registration.scope || url.href === index);
|
||||
const key = navigation ? index : url.href;
|
||||
if (!allowed.has(key)) return;
|
||||
event.respondWith((async () => {
|
||||
const cache = await caches.open(CACHE);
|
||||
// HTML, hashed bundles and stable model URLs come from the same release.
|
||||
// A new release stays waiting until the user chooses to restart or exits.
|
||||
const cached = await cache.match(key);
|
||||
return cached ?? fetch(event.request);
|
||||
})());
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { createRequire } from 'node:module';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
const require=createRequire(import.meta.url);
|
||||
const {chromium}=require(process.env.PLAYWRIGHT_MODULE||'playwright');
|
||||
const out='reference/workshop-preview';await mkdir(out+'/frames',{recursive:true});
|
||||
const browser=await chromium.launch({channel:'msedge',headless:true});
|
||||
try {
|
||||
const page=await browser.newPage({viewport:{width:1280,height:720},deviceScaleFactor:1});
|
||||
await page.goto('http://127.0.0.1:5176/?scene=archive');
|
||||
await page.waitForFunction(()=>window.rhine?.stats().ready);
|
||||
const apply=values=>page.evaluate(values=>window.wallpaperPropertyListener.applyUserProperties(Object.fromEntries(Object.entries(values).map(([k,value])=>[k,{value}]))),values);
|
||||
await apply({desktopmode:'workbench',boot:false,renderquality:'original',superperformance:false,sound:false,music:false,reduced:false,colortheme:'light',hudparallax:true,huddepth:20,hudtracking:false,screenfinish:false,uifrost:true,audioreactive:true,selectionstyle:'music-flat',rhythmstyle:'legacy',task1:'整理今日记录',task2:'阅读与学习',task3:'留一点时间休息'});
|
||||
await page.waitForTimeout(3500);
|
||||
await page.evaluate(()=>{window.previewSpectrum=setInterval(()=>{const t=performance.now()/1000;window.rhineWallpaperSpectrum={time:t,samples:Array.from({length:128},(_,i)=>.02+.065*(.5+.5*Math.sin(t*3-i*.21))*(.5+.5*Math.sin(t*1.7+i*.08)))}},33)});
|
||||
const frames=[],start=Date.now();let phase=0;
|
||||
for(let i=0;Date.now()-start<22000;i++) {
|
||||
const time=Date.now()-start;
|
||||
if(time>6000&&phase===0){await page.locator('[data-wb-lane="4"]').click();phase=1}
|
||||
if(time>10500&&phase===1){await apply({colortheme:'dark'});phase=2}
|
||||
if(time>16500&&phase===2){await page.locator('[data-wb-lane="0"]').click();await apply({colortheme:'light'});phase=3}
|
||||
const file=`frames/${String(i).padStart(4,'0')}.png`;
|
||||
await page.screenshot({path:out+'/'+file});frames.push({file,time:Date.now()-start});
|
||||
await page.waitForTimeout(95);
|
||||
}
|
||||
await writeFile(out+'/frames.json',JSON.stringify(frames,null,2));
|
||||
console.log(`Captured ${frames.length} actual browser frames over ${frames.at(-1).time}ms.`);
|
||||
}finally{await browser.close()}
|
||||
@@ -0,0 +1,280 @@
|
||||
// Original score; no recordings or melodies copied from the reference film.
|
||||
// node scripts/render-audio.mjs [path/to/ffmpeg.exe]
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
const rate = 48000,
|
||||
bpm = 72,
|
||||
beat = 60 / bpm,
|
||||
duration = 64 * beat;
|
||||
const length = Math.round(rate * duration),
|
||||
tau = Math.PI * 2;
|
||||
const folder = "public/audio";
|
||||
fs.mkdirSync(folder, { recursive: true });
|
||||
fs.mkdirSync(".tools/audio-render", { recursive: true });
|
||||
const hz = (midi) => 440 * 2 ** ((midi - 69) / 12);
|
||||
let seed = 93271;
|
||||
const random = () =>
|
||||
((seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0) / 4294967296) * 2 - 1;
|
||||
const stems = Object.fromEntries(
|
||||
["atmosphere", "motif", "pulse"].map((k) => [
|
||||
k,
|
||||
[new Float32Array(length), new Float32Array(length)],
|
||||
]),
|
||||
);
|
||||
function add(stem, start, seconds, pan, synth) {
|
||||
const out = stems[stem],
|
||||
offset = Math.round(start * rate);
|
||||
const l = Math.cos(((pan + 1) * Math.PI) / 4),
|
||||
r = Math.sin(((pan + 1) * Math.PI) / 4);
|
||||
for (let i = 0; i < Math.round(seconds * rate); i++) {
|
||||
const sample = synth(i / rate, i / (seconds * rate));
|
||||
const index = (((offset + i) % length) + length) % length;
|
||||
out[0][index] += sample * l;
|
||||
out[1][index] += sample * r;
|
||||
}
|
||||
}
|
||||
// Dmaj9 / Bm11 / Gmaj9 / Asus2, followed by a quieter answer.
|
||||
const chords = [
|
||||
[50, 57, 61, 64, 69],
|
||||
[47, 54, 57, 62, 64],
|
||||
[43, 54, 57, 62, 66],
|
||||
[45, 52, 57, 59, 64],
|
||||
[50, 57, 61, 64, 66],
|
||||
[47, 54, 57, 61, 64],
|
||||
[43, 50, 57, 59, 66],
|
||||
[45, 52, 57, 62, 64],
|
||||
];
|
||||
for (let bar = 0; bar < 8; bar++) {
|
||||
chords[bar].forEach((note, voice) => {
|
||||
const f = hz(note),
|
||||
seconds = beat * 10;
|
||||
add(
|
||||
"atmosphere",
|
||||
bar * 8 * beat - beat,
|
||||
seconds,
|
||||
(voice - 2) * 0.28,
|
||||
(t, p) => {
|
||||
const env = Math.sin(Math.PI * p) ** 2;
|
||||
const drift = 0.003 * Math.sin(tau * 0.17 * t + voice);
|
||||
return (
|
||||
0.035 *
|
||||
env *
|
||||
(Math.sin(tau * f * t + drift) +
|
||||
0.22 * Math.sin(tau * f * 2 * t) +
|
||||
0.12 * Math.sin(tau * f * 1.0007 * t))
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
// Quiet sub pulse, like equipment breathing, only twice per two-bar phrase.
|
||||
for (const b of [0, 4.5])
|
||||
add(
|
||||
"pulse",
|
||||
(bar * 8 + b) * beat,
|
||||
1.5,
|
||||
0,
|
||||
(t, p) =>
|
||||
0.12 *
|
||||
(1 - Math.exp(-t * 20)) *
|
||||
Math.exp(-t * 4) *
|
||||
(1 - p) *
|
||||
Math.sin(tau * hz(chords[bar][0] - 12) * t),
|
||||
);
|
||||
for (const b of [1.5, 3, 5.5, 7])
|
||||
add(
|
||||
"pulse",
|
||||
(bar * 8 + b) * beat,
|
||||
0.085,
|
||||
b < 4 ? -0.3 : 0.3,
|
||||
(t, p) =>
|
||||
0.018 *
|
||||
Math.sin(Math.PI * p) *
|
||||
Math.exp(-t * 55) *
|
||||
(random() * 0.35 + Math.sin(tau * 1240 * t) * 0.65),
|
||||
);
|
||||
}
|
||||
// Spacious, hand-written question/answer phrases, with deliberate rests.
|
||||
const phrases = [
|
||||
[
|
||||
[0, 74],
|
||||
[2.5, 76],
|
||||
[5, 69],
|
||||
],
|
||||
[
|
||||
[1, 73],
|
||||
[4, 69],
|
||||
],
|
||||
[
|
||||
[0, 71],
|
||||
[3, 74],
|
||||
[6, 78],
|
||||
],
|
||||
[
|
||||
[2, 76],
|
||||
[5.5, 71],
|
||||
],
|
||||
[
|
||||
[0, 78],
|
||||
[3, 76],
|
||||
[6, 73],
|
||||
],
|
||||
[
|
||||
[1, 74],
|
||||
[4.5, 69],
|
||||
],
|
||||
[
|
||||
[0, 71],
|
||||
[2.5, 69],
|
||||
[6, 66],
|
||||
],
|
||||
[
|
||||
[1, 69],
|
||||
[4, 76],
|
||||
],
|
||||
];
|
||||
phrases.forEach((phrase, bar) =>
|
||||
phrase.forEach(([b, note], i) => {
|
||||
const f = hz(note),
|
||||
velocity = 0.12 * (i === 0 ? 1 : 0.78);
|
||||
for (let echo = 0; echo < 4; echo++) {
|
||||
add(
|
||||
"motif",
|
||||
(bar * 8 + b) * beat + echo * beat * 0.75,
|
||||
4.8,
|
||||
(i % 2 ? 0.22 : -0.22) * (echo % 2 ? -1 : 1),
|
||||
(t, p) => {
|
||||
const attack = 1 - Math.exp(-t * 110),
|
||||
end = Math.min(1, (1 - p) * 12);
|
||||
const body = Math.sin(
|
||||
tau * f * t + 0.65 * Math.exp(-t * 7) * Math.sin(tau * f * 2 * t),
|
||||
);
|
||||
return (
|
||||
velocity *
|
||||
0.27 ** echo *
|
||||
attack *
|
||||
end *
|
||||
Math.exp(-t * 1.2) *
|
||||
(body +
|
||||
0.24 * Math.exp(-t * 2) * Math.sin(tau * f * 3 * t) +
|
||||
0.035 * Math.exp(-t * 8) * random())
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
// Circular, decorrelated early reflections keep the loop continuous.
|
||||
for (const [name, channels] of Object.entries(stems)) {
|
||||
const original = channels.map((c) => c.slice());
|
||||
for (const [seconds, gain] of [
|
||||
[0.071, 0.17],
|
||||
[0.113, 0.13],
|
||||
[0.193, 0.09],
|
||||
[0.307, 0.065],
|
||||
[0.487, 0.04],
|
||||
]) {
|
||||
const delay = Math.round(seconds * rate);
|
||||
for (let c = 0; c < 2; c++)
|
||||
for (let i = 0; i < length; i++)
|
||||
channels[c][(i + delay) % length] += original[1 - c][i] * gain;
|
||||
}
|
||||
if (name === "atmosphere")
|
||||
for (let c = 0; c < 2; c++) {
|
||||
let last = channels[c][length - 1];
|
||||
for (let i = 0; i < length; i++) {
|
||||
last += 0.16 * (channels[c][i] - last);
|
||||
channels[c][i] = last;
|
||||
}
|
||||
}
|
||||
}
|
||||
function wav(channels, file) {
|
||||
const data = Buffer.alloc(44 + length * 4);
|
||||
data.write("RIFF");
|
||||
data.writeUInt32LE(data.length - 8, 4);
|
||||
data.write("WAVEfmt ", 8);
|
||||
data.writeUInt32LE(16, 16);
|
||||
data.writeUInt16LE(1, 20);
|
||||
data.writeUInt16LE(2, 22);
|
||||
data.writeUInt32LE(rate, 24);
|
||||
data.writeUInt32LE(rate * 4, 28);
|
||||
data.writeUInt16LE(4, 32);
|
||||
data.writeUInt16LE(16, 34);
|
||||
data.write("data", 36);
|
||||
data.writeUInt32LE(length * 4, 40);
|
||||
for (let i = 0; i < length; i++)
|
||||
for (let c = 0; c < 2; c++)
|
||||
data.writeInt16LE(
|
||||
Math.round(Math.max(-1, Math.min(1, channels[c][i])) * 32767),
|
||||
44 + i * 4 + c * 2,
|
||||
);
|
||||
fs.writeFileSync(file, data);
|
||||
}
|
||||
const mix = [new Float32Array(length), new Float32Array(length)];
|
||||
const metrics = {};
|
||||
for (const [name, channels] of Object.entries(stems)) {
|
||||
for (const channel of channels)
|
||||
for (let i = 0; i < length; i++) channel[i] *= 2.2;
|
||||
let peak = 0,
|
||||
square = 0;
|
||||
for (let c = 0; c < 2; c++)
|
||||
for (let i = 0; i < length; i++) {
|
||||
const x = channels[c][i];
|
||||
peak = Math.max(peak, Math.abs(x));
|
||||
square += x * x;
|
||||
mix[c][i] += x;
|
||||
}
|
||||
metrics[name] = {
|
||||
peakDb: 20 * Math.log10(peak),
|
||||
rmsDb: 10 * Math.log10(square / (length * 2)),
|
||||
seamDelta: Math.max(...channels.map((c) => Math.abs(c[0] - c[length - 1]))),
|
||||
};
|
||||
const file = path.join(".tools/audio-render", name + ".wav");
|
||||
wav(channels, file);
|
||||
if (process.argv[2])
|
||||
execFileSync(process.argv[2], [
|
||||
"-y",
|
||||
"-v",
|
||||
"error",
|
||||
"-i",
|
||||
file,
|
||||
"-c:a",
|
||||
"libvorbis",
|
||||
"-q:a",
|
||||
"5",
|
||||
path.join(folder, name + ".ogg"),
|
||||
]);
|
||||
}
|
||||
wav(mix, ".tools/audio-render/observatory.wav");
|
||||
if (process.argv[2])
|
||||
execFileSync(process.argv[2], [
|
||||
"-y",
|
||||
"-v",
|
||||
"error",
|
||||
"-i",
|
||||
".tools/audio-render/observatory.wav",
|
||||
"-af",
|
||||
"afade=t=in:d=1,afade=t=out:st=50.833333:d=2.5",
|
||||
"-c:a",
|
||||
"libmp3lame",
|
||||
"-b:a",
|
||||
"192k",
|
||||
path.join(folder, "observatory-preview.mp3"),
|
||||
]);
|
||||
fs.writeFileSync(
|
||||
path.join(folder, "score.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
title: "Observatory / 观测室",
|
||||
bpm,
|
||||
duration,
|
||||
rate,
|
||||
seed: 93271,
|
||||
metrics,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
);
|
||||
console.log(JSON.stringify({ duration, metrics }, null, 2));
|
||||
@@ -0,0 +1,40 @@
|
||||
// Local-only phone review server. Reports contain timing and browser metrics.
|
||||
import { createServer } from "vite";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
const output = resolve(".tools/mobile-calibration");
|
||||
await mkdir(output, { recursive: true });
|
||||
const server = await createServer({
|
||||
server: { host: "0.0.0.0", port: 5189, strictPort: true },
|
||||
plugins: [{
|
||||
name: "local-mobile-calibration",
|
||||
configureServer(server) {
|
||||
server.middlewares.use("/__mobile-review", (req, res) => {
|
||||
if (req.method !== "POST" || req.headers.origin !== `http://${req.headers.host}`) {
|
||||
res.writeHead(403).end(); return;
|
||||
}
|
||||
let body = "";
|
||||
req.on("data", chunk => {
|
||||
body += chunk;
|
||||
if (body.length > 131072) { res.writeHead(413).end(); req.destroy(); }
|
||||
});
|
||||
req.on("end", async () => {
|
||||
try {
|
||||
const report = JSON.parse(body);
|
||||
if (report.version !== 1 || !Array.isArray(report.samples) || report.samples.length > 30)
|
||||
throw new Error("Invalid report");
|
||||
const id = `${Date.now()}-${randomUUID()}`;
|
||||
await writeFile(resolve(output, `${id}.json`), JSON.stringify({ receivedAt: new Date().toISOString(), ...report }, null, 2));
|
||||
res.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify({ id }));
|
||||
console.log(`Mobile calibration received: ${id} (${String(report.userAgent).slice(0, 180)})`);
|
||||
} catch { res.writeHead(400).end(); }
|
||||
});
|
||||
});
|
||||
},
|
||||
}],
|
||||
});
|
||||
await server.listen();
|
||||
server.printUrls();
|
||||
console.log("Open /reference/mobile-review.html on the phone; keep the tab visible during measurement.");
|
||||
@@ -0,0 +1,189 @@
|
||||
<#
|
||||
Rhine Lab UI · 一键启动(桌面那个脚本调的就是它)
|
||||
|
||||
做三件事:
|
||||
1. 确保本地归档后端(「数据库」,127.0.0.1:43117)在跑;没装开机自启就顺手装上。
|
||||
2. 在单独窗口里启动界面(npm run dev),窗口关掉即停止界面。
|
||||
3. 等界面真的响应了,再用默认浏览器打开它。
|
||||
|
||||
顺序很重要:先等后端首轮扫描结束再起界面。因为 back 端启动时会重建快照,
|
||||
而 npm run dev 的 predev 也会重建一次,两边同时写同一批文件会互相踩。
|
||||
|
||||
用法:
|
||||
-Mode start 一键启动(默认)
|
||||
-Mode stop 停掉界面与后端
|
||||
-Mode status 只看状态
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[ValidateSet('start', 'stop', 'status')]
|
||||
[string]$Mode = 'start'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$ProjectRoot = Split-Path -Parent $PSScriptRoot
|
||||
$ServiceScript = Join-Path $PSScriptRoot 'archive-service.ps1'
|
||||
$ConfigPath = Join-Path $ProjectRoot 'archive.config.json'
|
||||
$LogDir = Join-Path $ProjectRoot 'logs'
|
||||
$UiPidFile = Join-Path $LogDir 'ui.pid'
|
||||
$UiLog = Join-Path $LogDir 'ui.log'
|
||||
$UiErrLog = Join-Path $LogDir 'ui.err.log'
|
||||
$UiPortFirst = 5173
|
||||
$UiPortLast = 5185
|
||||
$UiMarker = 'ANALYSIS OS'
|
||||
|
||||
function Write-Head($text) { Write-Host ''; Write-Host " $text" -ForegroundColor Cyan }
|
||||
function Write-Ok($text) { Write-Host " $text" -ForegroundColor Green }
|
||||
function Write-Info($text) { Write-Host " $text" -ForegroundColor Gray }
|
||||
function Write-Warn2($text) { Write-Host " $text" -ForegroundColor Yellow }
|
||||
|
||||
function Get-ArchiveConfig {
|
||||
if (-not (Test-Path -LiteralPath $ConfigPath)) { throw "缺少配置文件:$ConfigPath" }
|
||||
return (Get-Content -LiteralPath $ConfigPath -Raw -Encoding UTF8 | ConvertFrom-Json)
|
||||
}
|
||||
|
||||
function Find-UiPort {
|
||||
foreach ($p in $UiPortFirst..$UiPortLast) {
|
||||
try {
|
||||
$r = Invoke-WebRequest -Uri "http://127.0.0.1:$p/" -TimeoutSec 2 -UseBasicParsing
|
||||
if ($r.StatusCode -eq 200 -and ([string]$r.Content) -like "*$UiMarker*") { return $p }
|
||||
} catch { }
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function Wait-UiPort {
|
||||
param([int]$TimeoutSec = 240)
|
||||
$deadline = (Get-Date).AddSeconds($TimeoutSec)
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
$p = Find-UiPort
|
||||
if ($p -gt 0) { return $p }
|
||||
Start-Sleep -Milliseconds 1000
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function Get-RecordedUiPid {
|
||||
if (-not (Test-Path -LiteralPath $UiPidFile)) { return 0 }
|
||||
$raw = (Get-Content -LiteralPath $UiPidFile -Raw).Trim()
|
||||
$value = 0
|
||||
if ([int]::TryParse($raw, [ref]$value)) { return $value }
|
||||
return 0
|
||||
}
|
||||
|
||||
function Stop-Ui {
|
||||
$stopped = $false
|
||||
|
||||
$recorded = Get-RecordedUiPid
|
||||
if ($recorded -gt 0 -and (Get-Process -Id $recorded -ErrorAction SilentlyContinue)) {
|
||||
& taskkill.exe /PID $recorded /T /F 2>&1 | Out-Null
|
||||
Write-Ok "已关闭界面窗口(进程树 $recorded)"
|
||||
$stopped = $true
|
||||
}
|
||||
|
||||
# 兜底:按命令行找这个项目的 vite 进程(例如用户自己敲的 npm run dev)
|
||||
$extra = @()
|
||||
foreach ($p in (Get-CimInstance Win32_Process -ErrorAction SilentlyContinue)) {
|
||||
$cl = [string]$p.CommandLine
|
||||
if ($cl -and $cl -like "*$ProjectRoot*vite*") { $extra += [int]$p.ProcessId }
|
||||
}
|
||||
foreach ($t in ($extra | Sort-Object -Unique)) {
|
||||
Stop-Process -Id $t -Force -ErrorAction SilentlyContinue
|
||||
$stopped = $true
|
||||
}
|
||||
if (-not $stopped) { Write-Warn2 '界面本来就没在运行。' }
|
||||
Remove-Item -LiteralPath $UiPidFile -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
function Show-Status {
|
||||
Write-Head '界面'
|
||||
$port = Find-UiPort
|
||||
if ($port -gt 0) {
|
||||
$recorded = Get-RecordedUiPid
|
||||
Write-Ok "在运行:http://127.0.0.1:$port/ (记录的 PID $recorded)"
|
||||
} else {
|
||||
Write-Info '未运行'
|
||||
}
|
||||
Write-Head '归档后端'
|
||||
& $ServiceScript -Action status
|
||||
}
|
||||
|
||||
switch ($Mode) {
|
||||
'status' { Show-Status }
|
||||
|
||||
'stop' {
|
||||
Write-Head '停止档案库'
|
||||
Stop-Ui
|
||||
& "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" `
|
||||
-NoProfile -ExecutionPolicy Bypass -File $ServiceScript -Action stop
|
||||
Write-Host ''
|
||||
}
|
||||
|
||||
'start' {
|
||||
Write-Head '启动档案库'
|
||||
|
||||
if (-not (Test-Path -LiteralPath $ServiceScript)) {
|
||||
throw "找不到服务脚本:$ServiceScript"
|
||||
}
|
||||
|
||||
# 1) 后端(「数据库」)
|
||||
# 放进子进程调用:这样它的 exit 码语义明确,也不会把本脚本一起 exit 掉。
|
||||
# (直接在同一个会话里 & 调用时 $LASTEXITCODE 可能是 $null,而 $null -ne 0 为真,
|
||||
# 会把「后端其实好好的」误报成失败。)
|
||||
Write-Info '检查归档后端……'
|
||||
& "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" `
|
||||
-NoProfile -ExecutionPolicy Bypass -File $ServiceScript -Action ensure
|
||||
$backendOk = ($LASTEXITCODE -eq 0)
|
||||
if (-not $backendOk) {
|
||||
Write-Warn2 '归档后端没能就绪 —— 界面照常启动,但 LOCAL ARCHIVE 面板与「文件位置」会不可用。'
|
||||
}
|
||||
|
||||
$cfg = Get-ArchiveConfig
|
||||
|
||||
# 2) 界面
|
||||
$port = Find-UiPort
|
||||
if ($port -gt 0) {
|
||||
Write-Ok "界面已经在跑:http://127.0.0.1:$port/"
|
||||
} else {
|
||||
if (-not (Test-Path -LiteralPath $LogDir)) { New-Item -ItemType Directory -Path $LogDir -Force | Out-Null }
|
||||
$npm = (Get-Command npm.cmd -ErrorAction SilentlyContinue)
|
||||
if (-not $npm) { throw '找不到 npm.cmd,请确认 Node.js 已正确安装。' }
|
||||
|
||||
Write-Info '启动界面(后台静默运行 npm run dev,日志写进 logs\ui.log)……'
|
||||
# 隐藏窗口 + 重定向输出:一是干净,二是万一起不来,日志就在手边可查。
|
||||
# (反过来,不重定向时 Start-Process 在非交互宿主里可能连窗口都建不出来,
|
||||
# 表现就是「什么都没发生」,排查时毫无线索。)
|
||||
$proc = Start-Process -FilePath $npm.Source -ArgumentList @('run', 'dev') `
|
||||
-WorkingDirectory $ProjectRoot -PassThru -WindowStyle Hidden `
|
||||
-RedirectStandardOutput $UiLog -RedirectStandardError $UiErrLog
|
||||
Set-Content -LiteralPath $UiPidFile -Value $proc.Id -Encoding ASCII
|
||||
|
||||
Write-Info '等待界面就绪(predev 会先重建一次索引,约十几秒)……'
|
||||
$port = Wait-UiPort -TimeoutSec 300
|
||||
if ($port -le 0) {
|
||||
Write-Warn2 '界面在超时前没有响应。下面是它自己的输出:'
|
||||
Write-Host ''
|
||||
if (Test-Path -LiteralPath $UiLog) { Get-Content -LiteralPath $UiLog -Tail 20 -Encoding UTF8 | ForEach-Object { Write-Host " $_" } }
|
||||
if ((Test-Path -LiteralPath $UiErrLog) -and (Get-Item -LiteralPath $UiErrLog).Length -gt 0) {
|
||||
Write-Host ''
|
||||
Write-Host ' --- stderr ---' -ForegroundColor DarkGray
|
||||
Get-Content -LiteralPath $UiErrLog -Tail 20 -Encoding UTF8 | ForEach-Object { Write-Host " $_" }
|
||||
}
|
||||
Write-Host ''
|
||||
exit 1
|
||||
}
|
||||
Write-Ok "界面已就绪:http://127.0.0.1:$port/"
|
||||
}
|
||||
|
||||
# 3) 开浏览器
|
||||
Start-Process ("http://127.0.0.1:$port/") | Out-Null
|
||||
Write-Ok '已用默认浏览器打开。'
|
||||
|
||||
Write-Host ''
|
||||
Write-Info "归档根:$($cfg.root)"
|
||||
Write-Info '要停止:运行「启动档案库.cmd stop」(界面与后端一起停)'
|
||||
Write-Info "界面日志:$UiLog"
|
||||
Write-Host ''
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// npm pack [email protected] --ignore-scripts --pack-destination .tools/font-comparison
|
||||
// Pass its extracted package directory as the first argument. Font bytes are
|
||||
// copied unchanged; only CSS family/weight names and relative URLs are adapted.
|
||||
import { readFile, writeFile, mkdir, copyFile } from 'node:fs/promises';
|
||||
import { resolve, dirname, basename } from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
const source = resolve(process.argv[2] || '.tools/font-comparison/mobeicanyue/package');
|
||||
const version = 'misans-webfont-4.3.1';
|
||||
const destination = resolve('public/fonts', version);
|
||||
const archive = resolve(source, '../../misans-webfont-4.3.1.tgz');
|
||||
const expected = 'lVU2j0sS0Ah/DjcqPk7X/2Vo7P2498bWQnpt9ruxHnxqzeaC2caQ+CmHjxNCujiDhHHX0q0w9cfabh9Yj5gG9g==';
|
||||
if (createHash('sha512').update(await readFile(archive)).digest('base64') !== expected) throw Error('Unexpected npm archive integrity');
|
||||
let css = '/* Generated by scripts/vendor-fonts.mjs from [email protected]. */\n';
|
||||
const files = [];
|
||||
for (const [weight,label] of [[300,'light'],[400,'regular'],[600,'demibold'],[700,'bold']]) {
|
||||
const sheet = resolve(source, `misans/misans-${label}/result.css`);
|
||||
const upstream = await readFile(sheet,'utf8');
|
||||
await mkdir(resolve(destination,label),{recursive:true});
|
||||
for (const [,body] of upstream.matchAll(/@font-face\s*\{([^}]+)\}/g)) {
|
||||
const url = body.match(/url\(['"]?([^'"\)]+)['"]?\)/)[1];
|
||||
const range = body.match(/unicode-range:([^;}]*)/i)[1];
|
||||
const file = basename(url);
|
||||
if (!/^\d+\.woff2$/.test(file)) throw Error(`Unexpected font file: ${file}`);
|
||||
const bytes = await readFile(resolve(dirname(sheet),file));
|
||||
await writeFile(resolve(destination,label,file),bytes);
|
||||
files.push({path:`${label}/${file}`,bytes:bytes.length,sha256:createHash('sha256').update(bytes).digest('hex')});
|
||||
css += `@font-face{font-family:"MiSans";font-style:normal;font-weight:${weight};font-display:swap;src:url("/fonts/${version}/${label}/${file}") format("woff2");unicode-range:${range}}\n`;
|
||||
}
|
||||
await copyFile(sheet, resolve(destination,label,'upstream.css.txt'));
|
||||
}
|
||||
await copyFile(resolve(source,'README.md'),resolve(destination,'UPSTREAM-README.md'));
|
||||
await writeFile('src/fonts.css',css);
|
||||
await writeFile(resolve(destination,'source.json'),JSON.stringify({
|
||||
package:'misans-webfont',version:'4.3.1',fontVersion:'4.003',
|
||||
repository:'https://github.com/mobeicanyue/misans-webfont',
|
||||
archive:'https://registry.npmjs.org/misans-webfont/-/misans-webfont-4.3.1.tgz',
|
||||
integrity:`sha512-${expected}`,fontFilesModified:false,
|
||||
cssChanges:'One MiSans family with 300/400/600/700 weights; remove local() so installed fonts cannot replace pinned files; use versioned same-origin URLs.',files,
|
||||
},null,2)+'\n');
|
||||
console.log(`Vendored ${files.length} unchanged font shards, ${(files.reduce((n,f)=>n+f.bytes,0)/1048576).toFixed(2)} MiB.`);
|
||||
Reference in New Issue
Block a user