Files
Find-Word/scripts/check-build.mjs
T

252 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { readFileSync, readdirSync } from "node:fs";
import { createHash } from "node:crypto";
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { AUDIO_ASSET_VERSION } from "../src/audio/assets.ts";
import { ENVIRONMENTS, PATTERNS, WORDS } from "../src/content.ts";
// 빌드가 성공해도 public 파일이 빠지면 게임은 실행 중 실패한다. 따라서 배포
// 폴더 자체의 파일 해시를 원본 manifest와 비교하는 계약을 빌드 마지막에 둔다.
export function verifyBuild(directory) {
// 번들에 실제 포함되는 전이 의존성도 잠금 파일에서 찾는다. 프로젝트의
// 비공개 라이선스와 별개로 설치된 MIT 전문·버전을 사본 세 곳에서 보존한다.
try {
const notice = readFileSync("THIRD_PARTY_NOTICES.md", "utf8");
const lock = JSON.parse(readFileSync("package-lock.json", "utf8"));
for (const [path, pkg] of Object.entries(lock.packages)) {
if (!path || pkg.dev) continue;
const name = path.split("node_modules/").at(-1);
const license = readFileSync(`${path}/LICENSE`, "utf8").trim();
if (!notice.includes(`## ${name} ${pkg.version}\n\n${license}`))
throw new Error(`${name} ${pkg.version}의 전문이 없습니다.`);
}
for (const path of [
"public/THIRD_PARTY_NOTICES.md",
resolve(directory, "THIRD_PARTY_NOTICES.md"),
]) {
if (readFileSync(path, "utf8") !== notice)
throw new Error(`${path}가 원본과 다릅니다.`);
}
} catch (cause) {
throw new Error("런타임 의존성 notice 누락 또는 불일치", { cause });
}
const manifest = JSON.parse(
readFileSync("public/audio/manifest.json", "utf8"),
);
const builtManifest = JSON.parse(
readFileSync(resolve(directory, "audio/manifest.json"), "utf8"),
);
if (
manifest.version !== AUDIO_ASSET_VERSION ||
JSON.stringify(manifest) !== JSON.stringify(builtManifest)
)
throw new Error("빌드 음성 목록이 원본과 다릅니다.");
// manifest끼리 일치해도 함께 축약될 수 있으므로 앱 콘텐츠에서 독립 재구성한다.
const expectedVoice = [
"intro.wav",
...PATTERNS.flatMap((pattern) =>
WORDS.map((word) => `${pattern.id}-${word.id}.wav`),
),
].sort();
if (
expectedVoice.length !== 101 ||
manifest.files.length !== 101 ||
JSON.stringify(manifest.files.map((entry) => entry.file).sort()) !==
JSON.stringify(expectedVoice)
)
throw new Error("음성은 정확한 예상 목록 101개가 필요합니다.");
if (
JSON.stringify(readdirSync(resolve(directory, "audio")).sort()) !==
JSON.stringify([...expectedVoice, "manifest.json"].sort())
)
throw new Error("빌드 음성 파일 누락 또는 추가 파일");
for (const entry of manifest.files) {
const bytes = readFileSync(resolve(directory, "audio", entry.file));
if (createHash("sha256").update(bytes).digest("hex") !== entry.sha256)
throw new Error(`빌드 자산 해시 불일치: ${entry.file}`);
}
// 현장음은 별도 라이선스가 있는 독립 자산이다. 누락뿐 아니라 큰 원본이
// 잘못 섞여 배포되는 경우도 디렉터리 파일 집합을 엄격히 비교해 차단한다.
try {
const source = readFileSync("public/ambience/manifest.json", "utf8");
if (
readFileSync(resolve(directory, "ambience/manifest.json"), "utf8") !==
source
)
throw new Error("manifest 불일치");
const ambience = JSON.parse(source);
// manifest 사본끼리 같아도 버전·청감 계약을 함께 바꿀 수 있으므로 릴리스 고정값과 대조한다.
// 실제 파일의 독립 FFmpeg 실측은 Python suite가 수행하고 빌드는 그 바이트 해시를 보존한다.
const normalization = ambience.normalization;
if (
ambience.version !== AUDIO_ASSET_VERSION ||
normalization?.targetIntegratedLUFS !== -32.5 ||
normalization?.truePeakCeilingDBTP !== -6 ||
normalization?.internalTruePeakCeilingDBTP !== -6.2 ||
normalization?.measurementFilter !== "ebur128=peak=true" ||
normalization?.maximumIterations !== 3 ||
normalization?.toleranceLU !== 0.2 ||
normalization?.maximumSpreadLU !== 0.4 ||
!ambience.toolVersions?.ffmpeg?.startsWith("ffmpeg version ")
)
throw new Error("현장음 버전/정규화 계약 불일치");
const integrated = []; // 최종 저장 WAV의 전용 ebur128 실측만 릴리스 통계로 인정한다.
const plan = JSON.parse(
readFileSync(".agent/AMBIENCE-SOURCES-v0.2.3.json", "utf8"),
);
if (
JSON.stringify(
ambience.files.map((entry) => entry.environmentId).sort(),
) !== JSON.stringify(ENVIRONMENTS.map((entry) => entry.id).sort())
)
throw new Error("현장음 예상 환경 목록 불일치");
if (
ambience.files.length !== 8 ||
new Set(ambience.files.map((e) => e.sha256)).size !== 8
)
throw new Error("고유 녹음 8개가 필요합니다.");
const expected = [
...ambience.files.map((e) => e.file),
"manifest.json",
"AMBIENCE_NOTICES.md",
].sort();
if (
JSON.stringify(readdirSync(resolve(directory, "ambience")).sort()) !==
JSON.stringify(expected)
)
throw new Error("누락 또는 원본 파일 혼입");
const notice = readFileSync("public/ambience/AMBIENCE_NOTICES.md", "utf8");
if (
readFileSync(
resolve(directory, "ambience/AMBIENCE_NOTICES.md"),
"utf8",
) !== notice
)
throw new Error("고지 불일치");
for (const entry of ambience.files) {
const bytes = readFileSync(resolve(directory, "ambience", entry.file));
if (
bytes.length !== entry.bytes ||
createHash("sha256").update(bytes).digest("hex") !== entry.sha256
)
throw new Error(`자산 해시 불일치: ${entry.file}`);
const approved = plan.assets.find(
(asset) => asset.environmentId === entry.environmentId,
);
if (
!approved ||
Object.entries(approved).some(
([key, value]) =>
JSON.stringify(entry[key]) !== JSON.stringify(value),
) ||
entry.file !== `${entry.environmentId}.wav` ||
entry.sourceSha256 !== approved.expectedSourceSha256 ||
entry.outputLicense !== approved.license ||
!Number.isFinite(approved.selectedSegmentStartSeconds) ||
approved.selectedSegmentStartSeconds < 0 ||
!Number.isFinite(approved.directMediaDurationSeconds) ||
approved.selectedSegmentStartSeconds + 12 >
approved.directMediaDurationSeconds ||
entry.conversion?.startSeconds !==
approved.selectedSegmentStartSeconds ||
entry.conversion?.durationSeconds !== 12 ||
entry.duration !== 12 ||
typeof entry.conversion?.operations !== "string" ||
!entry.conversion.operations.trim()
)
throw new Error(`승인 출처/변경 메타데이터 불일치: ${entry.file}`);
const loudness = entry.conversion.loudness;
const keys = [
"integratedLUFS",
"truePeakDBTP",
"loudnessRangeLU",
"thresholdLUFS",
];
const finiteStats = (stats) =>
stats &&
keys.every(
(key) =>
typeof stats[key] === "number" && Number.isFinite(stats[key]),
);
if (
!loudness ||
loudness.measurementFilter !== "ebur128=peak=true" ||
!/^[a-f0-9]{64}$/.test(loudness.fadedPcmSha256) ||
!finiteStats(loudness.input) ||
!finiteStats(loudness.final) ||
!Array.isArray(loudness.iterations) ||
loudness.iterations.length < 1 ||
loudness.iterations.length > 3
)
throw new Error(`선형 ebur128 provenance 누락: ${entry.file}`);
let gain = -32.5 - loudness.input.integratedLUFS;
for (const [index, iteration] of loudness.iterations.entries()) {
const predicted = loudness.input.truePeakDBTP + gain;
if (
typeof iteration.gainDB !== "number" ||
!Number.isFinite(iteration.gainDB) ||
Math.abs(iteration.gainDB - gain) > 1e-9 ||
iteration.filter !==
`volume=${gain.toFixed(12)}dB:precision=double` ||
typeof iteration.predictedTruePeakDBTP !== "number" ||
!Number.isFinite(iteration.predictedTruePeakDBTP) ||
Math.abs(iteration.predictedTruePeakDBTP - predicted) > 1e-9 ||
predicted > -6.2 + 1e-9 ||
!finiteStats(iteration.output) ||
iteration.output.truePeakDBTP > -6.2
)
throw new Error(`선형 gain/peak provenance 불일치: ${entry.file}`);
const error = -32.5 - iteration.output.integratedLUFS;
if (
index < loudness.iterations.length - 1 &&
Math.abs(error) <= 0.2 + 1e-9
)
throw new Error(`불필요한 보정 반복: ${entry.file}`);
gain += error;
}
const last = loudness.iterations.at(-1).output;
const measuredI = loudness.final.integratedLUFS;
if (
keys.some((key) => last[key] !== loudness.final[key]) ||
Math.abs(measuredI + 32.5) > 0.2 + 1e-9 ||
loudness.final.truePeakDBTP > -6
)
throw new Error(`최종 ebur128 loudness/true peak 위반: ${entry.file}`);
integrated.push(measuredI);
// 다른 항목에 같은 라이선스 URL이 있어도 이 항목의 누락을 가리지 못한다.
const sections = notice
.split("## ")
.filter((section) =>
section.startsWith(`${entry.displayName} — ${entry.environmentId}\n`),
);
const lines = sections[0]?.split("\n") ?? [];
if (
sections.length !== 1 ||
![
entry.attribution,
`Creator: ${entry.creator}`,
`Source: ${entry.pageUrl}`,
`Download: ${entry.directUrl}`,
`License: ${entry.license} — ${entry.licenseUrl}`,
`Output license: ${entry.outputLicense}`,
`Changes: ${entry.conversion.operations}; source seconds ${entry.conversion.startSeconds}–${entry.conversion.startSeconds + entry.conversion.durationSeconds}.`,
].every((line) => lines.includes(line))
)
throw new Error(`출처 고지 누락: ${entry.file}`);
}
if (Math.max(...integrated) - Math.min(...integrated) > 0.4 + 1e-9)
throw new Error("현장음 loudness spread 위반");
} catch (cause) {
throw new Error("현장음 자산/manifest/notice 누락 또는 불일치", { cause });
}
return manifest.files.length;
}
if (
process.argv[1] &&
import.meta.url === pathToFileURL(resolve(process.argv[1])).href
) {
console.log(
`빌드 음성 자산 ${verifyBuild("dist")}개 + 현장음 8개 및 런타임/현장음 notice 확인`,
);
}