2026-10-14 10:01:46 +00:00
|
|
|
|
import { readFileSync, readdirSync } from "node:fs";
|
2026-10-14 06:19:24 +00:00
|
|
|
|
import { createHash } from "node:crypto";
|
|
|
|
|
|
import { resolve } from "node:path";
|
|
|
|
|
|
import { pathToFileURL } from "node:url";
|
2026-10-14 12:12:13 +00:00
|
|
|
|
import { AUDIO_ASSET_VERSION } from "../src/audio/assets.ts";
|
2026-10-14 10:01:46 +00:00
|
|
|
|
import { ENVIRONMENTS, PATTERNS, WORDS } from "../src/content.ts";
|
2026-10-14 06:19:24 +00:00
|
|
|
|
|
|
|
|
|
|
// 빌드가 성공해도 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"),
|
|
|
|
|
|
);
|
2026-10-14 12:12:13 +00:00
|
|
|
|
if (
|
|
|
|
|
|
manifest.version !== AUDIO_ASSET_VERSION ||
|
|
|
|
|
|
JSON.stringify(manifest) !== JSON.stringify(builtManifest)
|
|
|
|
|
|
)
|
2026-10-14 06:19:24 +00:00
|
|
|
|
throw new Error("빌드 음성 목록이 원본과 다릅니다.");
|
2026-10-14 10:01:46 +00:00
|
|
|
|
// 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("빌드 음성 파일 누락 또는 추가 파일");
|
2026-10-14 06:19:24 +00:00
|
|
|
|
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}`);
|
|
|
|
|
|
}
|
2026-10-14 10:01:46 +00:00
|
|
|
|
// 현장음은 별도 라이선스가 있는 독립 자산이다. 누락뿐 아니라 큰 원본이
|
|
|
|
|
|
// 잘못 섞여 배포되는 경우도 디렉터리 파일 집합을 엄격히 비교해 차단한다.
|
|
|
|
|
|
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);
|
2026-10-14 12:12:13 +00:00
|
|
|
|
// 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 실측만 릴리스 통계로 인정한다.
|
2026-10-14 10:01:46 +00:00
|
|
|
|
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}`);
|
2026-10-14 12:12:13 +00:00
|
|
|
|
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);
|
2026-10-14 10:01:46 +00:00
|
|
|
|
// 다른 항목에 같은 라이선스 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}`);
|
|
|
|
|
|
}
|
2026-10-14 12:12:13 +00:00
|
|
|
|
if (Math.max(...integrated) - Math.min(...integrated) > 0.4 + 1e-9)
|
|
|
|
|
|
throw new Error("현장음 loudness spread 위반");
|
2026-10-14 10:01:46 +00:00
|
|
|
|
} catch (cause) {
|
|
|
|
|
|
throw new Error("현장음 자산/manifest/notice 누락 또는 불일치", { cause });
|
|
|
|
|
|
}
|
2026-10-14 06:19:24 +00:00
|
|
|
|
return manifest.files.length;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (
|
|
|
|
|
|
process.argv[1] &&
|
|
|
|
|
|
import.meta.url === pathToFileURL(resolve(process.argv[1])).href
|
|
|
|
|
|
) {
|
|
|
|
|
|
console.log(
|
2026-10-14 10:01:46 +00:00
|
|
|
|
`빌드 음성 자산 ${verifyBuild("dist")}개 + 현장음 8개 및 런타임/현장음 notice 확인`,
|
2026-10-14 06:19:24 +00:00
|
|
|
|
);
|
|
|
|
|
|
}
|