feat: release Find-Word v0.2.3
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
"""Find-Word v0.2.3 현장 녹음 파생본과 기계 판독 provenance를 만든다.
|
||||
|
||||
원본은 명시적으로 받은 /tmp 캐시에만 두고 네트워크를 호출하지 않는다.
|
||||
FFmpeg는 구간 선택·다운믹스·리샘플만 담당하며 fade와 선형 gain은 여기서
|
||||
결정한다. 원본/출력 해시와 실제 변환 설정으로 다른 사람이 결과를 추적한다.
|
||||
"""
|
||||
from array import array
|
||||
from datetime import datetime, timezone
|
||||
import ctypes
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import wave
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
# 환경 목록만 고정한다. 구간 시작점은 승인 계획의 불변 provenance에서만 읽는다.
|
||||
ENVIRONMENT_IDS = frozenset(("street", "mall", "theater", "department", "market", "subway", "cafe", "rain"))
|
||||
EXCERPT_SECONDS = 12
|
||||
SAMPLE_RATE = 24000
|
||||
FADE_SECONDS = 0.05
|
||||
|
||||
|
||||
def digest(path):
|
||||
"""큰 원본도 메모리에 전부 적재하지 않고 SHA-256을 계산한다."""
|
||||
with Path(path).open("rb") as stream:
|
||||
return hashlib.file_digest(stream, "sha256").hexdigest()
|
||||
|
||||
|
||||
def convert(source, destination, start, duration=EXCERPT_SECONDS):
|
||||
"""실제 PCM에 짧은 양끝 fade와 공통 gain을 적용해 24kHz mono PCM16으로 쓴다.
|
||||
|
||||
start/duration은 원본의 초 단위 구간이다. RMS 목표 .08, peak .85,
|
||||
최대 증폭 4배 중 가장 작은 gain으로 과증폭을 막고 파형의 비율을 보존한다.
|
||||
"""
|
||||
if not 8 <= duration <= 20 or not math.isfinite(start) or start < 0:
|
||||
raise ValueError("현장음 구간은 8~20초, 시작점은 0 이상이어야 합니다.")
|
||||
output_options = ["-t", str(duration), "-ar", str(SAMPLE_RATE), "-ac", "1", "-f", "f32le", "pipe:1"]
|
||||
seek_method = "input seek"
|
||||
decoded = subprocess.run([
|
||||
"ffmpeg", "-v", "error", "-ss", str(start), "-i", str(source), *output_options,
|
||||
], check=True, capture_output=True, timeout=90).stdout
|
||||
if len(decoded) < duration * SAMPLE_RATE * 4:
|
||||
# 일부 Ogg seek/timestamp 조합은 몇 프레임이 짧아진다. 처음부터 디코드하여
|
||||
# 승인 초를 24kHz sample 경계로 선택한다. PCM 추가·패딩·시간 변형은 하지 않는다.
|
||||
# 출력 PTS를 sample 수로 다시 매겨 원본 PTS 불연속이 -t 길이를 줄이지 않게 한다.
|
||||
seek_method = "full decode then sample trim"
|
||||
first_frame = round(start * SAMPLE_RATE)
|
||||
last_frame = first_frame + round(duration * SAMPLE_RATE)
|
||||
selection = f"aresample={SAMPLE_RATE},atrim=start_sample={first_frame}:end_sample={last_frame},asetpts=N/SR/TB"
|
||||
decoded = subprocess.run([
|
||||
"ffmpeg", "-v", "error", "-i", str(source), "-af", selection, *output_options,
|
||||
], check=True, capture_output=True, timeout=90).stdout
|
||||
samples = array("f", decoded)
|
||||
if sys.byteorder != "little":
|
||||
samples.byteswap()
|
||||
if len(samples) != duration * SAMPLE_RATE or not all(math.isfinite(x) for x in samples):
|
||||
raise ValueError("원본 구간이 잘렸거나 PCM이 올바르지 않습니다.")
|
||||
# 경계의 DC도 함께 0으로 내려 루프 접합점의 불연속 클릭을 막는다.
|
||||
fade_frames = round(FADE_SECONDS * SAMPLE_RATE)
|
||||
faded = [x * min(1, i / fade_frames, (len(samples) - 1 - i) / fade_frames)
|
||||
for i, x in enumerate(samples)]
|
||||
rms = math.sqrt(sum(x*x for x in faded) / len(faded))
|
||||
peak = max(abs(x) for x in faded)
|
||||
if rms < 0.001:
|
||||
raise ValueError("무음 또는 지나치게 작은 현장 녹음입니다.")
|
||||
gain = min(0.08 / rms, 0.85 / peak, 4)
|
||||
pcm = array("h", (round(x * gain * 32767) for x in faded))
|
||||
measured_rms = math.sqrt(sum(x*x for x in pcm) / len(pcm)) / 32768
|
||||
measured_peak = max(abs(x) for x in pcm) / 32768
|
||||
if sys.byteorder != "little":
|
||||
pcm.byteswap()
|
||||
with wave.open(str(destination), "wb") as stream:
|
||||
stream.setparams((1, 2, SAMPLE_RATE, 0, "NONE", "not compressed"))
|
||||
stream.writeframes(pcm.tobytes())
|
||||
return {"sha256": digest(destination), "bytes": Path(destination).stat().st_size,
|
||||
"duration": len(samples) / SAMPLE_RATE, "rms": measured_rms, "peak": measured_peak,
|
||||
"conversion": {"startSeconds": start, "durationSeconds": duration,
|
||||
"seekMethod": seek_method,
|
||||
"fadeSeconds": FADE_SECONDS, "sampleRate": SAMPLE_RATE,
|
||||
"channels": 1, "encoding": "PCM16 little-endian", "linearGain": gain,
|
||||
"targetRms": 0.08, "peakLimit": 0.85, "maxGain": 4,
|
||||
"operations": "excerpt, downmix, resample, linear end fades, bounded linear normalization, WAV encoding"}}
|
||||
|
||||
|
||||
def source_path(directory, environment):
|
||||
"""승인된 환경의 직접 미디어 확장자를 사용한다."""
|
||||
extension = ".mp3" if environment in ("theater", "department") else ".flac" if environment == "rain" else ".ogg"
|
||||
return Path(directory) / (environment + extension)
|
||||
|
||||
|
||||
def validate_plan(plan):
|
||||
"""선택 구간은 직접 미디어 길이 안의 유한한 수여야 하며 보정하거나 추정하지 않는다."""
|
||||
assets = plan["assets"]
|
||||
if len(assets) != 8 or {a["environmentId"] for a in assets} != ENVIRONMENT_IDS:
|
||||
raise ValueError("정확한 승인 원본 8개가 필요합니다.")
|
||||
for asset in assets:
|
||||
start = asset.get("selectedSegmentStartSeconds")
|
||||
duration = asset.get("directMediaDurationSeconds")
|
||||
if (any(type(value) not in (int, float) or not math.isfinite(value) for value in (start, duration))
|
||||
or start < 0 or duration <= 0 or start + EXCERPT_SECONDS > duration):
|
||||
raise ValueError(f"{asset['environmentId']}: 승인 구간은 직접 미디어 길이 안의 12초여야 합니다.")
|
||||
|
||||
|
||||
def verify_sources(plan, directory):
|
||||
"""여덟 원본 모두의 불변 승인값을 확인한 뒤에만 첫 변환을 허용한다."""
|
||||
validate_plan(plan)
|
||||
assets = plan["assets"]
|
||||
for asset in assets:
|
||||
source = source_path(directory, asset["environmentId"])
|
||||
if (not source.is_file() or source.is_symlink()
|
||||
or source.stat().st_size != asset.get("sourceBytes")
|
||||
or digest(source) != asset.get("expectedSourceSha256")):
|
||||
raise ValueError(f"{asset['environmentId']}: 승인 원본 SHA-256/bytes 불일치")
|
||||
|
||||
|
||||
def render_notice(files):
|
||||
"""각 항목 자체에 저작자·두 URL·원본/파생 라이선스·변경 고지를 완결한다."""
|
||||
lines = ["# Find-Word v0.2.3 — Field recording notices", "",
|
||||
"These third-party recordings retain their own licenses. The private project license does not apply to them.",
|
||||
"All files are modified excerpts: trimmed, faded, downmixed, resampled, normalized and converted to WAV.",
|
||||
"The market derivative remains CC BY-SA 4.0. No additional restrictions are imposed on that audio.", ""]
|
||||
for entry in files:
|
||||
conversion = entry["conversion"]
|
||||
start = conversion["startSeconds"]
|
||||
end = start + conversion["durationSeconds"]
|
||||
lines += [f"## {entry['displayName']} — {entry['environmentId']}", "", entry["attribution"], "",
|
||||
f"Creator: {entry['creator']}", f"Source: {entry['pageUrl']}",
|
||||
f"Download: {entry['directUrl']}", f"License: {entry['license']} — {entry['licenseUrl']}",
|
||||
f"Output license: {entry['outputLicense']}",
|
||||
f"Changes: {conversion['operations']}; source seconds {start}–{end}.", ""]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def validate_bundle(directory, plan):
|
||||
"""공개 전 staged 파일을 다시 읽어 manifest 자기 보고와 실제 PCM을 대조한다."""
|
||||
try:
|
||||
validate_plan(plan)
|
||||
expected = {f"{name}.wav" for name in ENVIRONMENT_IDS} | {"manifest.json", "AMBIENCE_NOTICES.md"}
|
||||
if {p.name for p in directory.iterdir()} != expected or any(
|
||||
p.is_symlink() or not p.is_file() for p in directory.iterdir()):
|
||||
raise ValueError("현장음 파일 누락·추가·symlink")
|
||||
manifest = json.loads((directory / "manifest.json").read_text())
|
||||
entries = manifest["files"]
|
||||
if (manifest["version"] != "0.2.3" or len(entries) != 8
|
||||
or {e["environmentId"] for e in entries} != ENVIRONMENT_IDS
|
||||
or len({e["sha256"] for e in entries}) != 8):
|
||||
raise ValueError("고유한 승인 현장음 8개가 필요합니다.")
|
||||
approved = {a["environmentId"]: a for a in plan["assets"]}
|
||||
for entry in entries:
|
||||
environment = entry["environmentId"]
|
||||
asset = approved[environment]
|
||||
if (any(entry.get(key) != value for key, value in asset.items())
|
||||
or entry["file"] != f"{environment}.wav"
|
||||
or entry["sourceSha256"] != asset["expectedSourceSha256"]
|
||||
or entry["outputLicense"] != asset["license"]
|
||||
or not entry["conversion"]["operations"].strip()):
|
||||
raise ValueError("승인 출처 또는 파생본 메타데이터 불일치")
|
||||
path = directory / entry["file"]
|
||||
if path.stat().st_size != entry["bytes"] or digest(path) != entry["sha256"]:
|
||||
raise ValueError("현장음 해시/bytes 불일치")
|
||||
with wave.open(str(path), "rb") as stream:
|
||||
if (stream.getframerate(), stream.getnchannels(), stream.getsampwidth(), stream.getcomptype()) != (SAMPLE_RATE, 1, 2, "NONE"):
|
||||
raise ValueError("24kHz mono PCM16이 아닙니다.")
|
||||
frames = stream.getnframes()
|
||||
raw = stream.readframes(frames)
|
||||
samples = array("h", raw)
|
||||
if sys.byteorder != "little": samples.byteswap()
|
||||
if (frames != EXCERPT_SECONDS * SAMPLE_RATE or len(raw) != frames * 2 or path.stat().st_size != len(raw) + 44
|
||||
or samples[0] != 0 or samples[-1] != 0):
|
||||
raise ValueError("12초 PCM 또는 fade 경계 불일치")
|
||||
rms = math.sqrt(sum(x*x for x in samples) / frames) / 32768
|
||||
peak = max(abs(x) for x in samples) / 32768
|
||||
if (rms <= 0.003 or peak > 0.85 or not math.isclose(rms, entry["rms"], abs_tol=1e-12)
|
||||
or not math.isclose(peak, entry["peak"], abs_tol=1e-12)
|
||||
or entry["duration"] != frames / SAMPLE_RATE
|
||||
or entry["conversion"]["startSeconds"] != asset["selectedSegmentStartSeconds"]
|
||||
or entry["conversion"]["durationSeconds"] != EXCERPT_SECONDS
|
||||
or entry["conversion"]["fadeSeconds"] != FADE_SECONDS):
|
||||
raise ValueError("현장음 실측값/변환 불일치")
|
||||
if (directory / "AMBIENCE_NOTICES.md").read_text() != render_notice(entries):
|
||||
raise ValueError("항목별 출처/변경 고지 불일치")
|
||||
except (KeyError, TypeError, json.JSONDecodeError, wave.Error, EOFError) as error:
|
||||
raise ValueError("현장음 bundle 검증 실패") from error
|
||||
|
||||
|
||||
def atomic_publish(staged, output):
|
||||
"""Linux의 단일 renameat2 교환이 commit 지점이다. 실패 시 기존 이름은 유지된다.
|
||||
|
||||
성공 전에는 기존 디렉터리를 건드리지 않는다. 성공 후에는 완성된 새 디렉터리만
|
||||
그 이름에 보이며 이전 트리는 staging에 남는다. 두 번 rename하는 fallback은 없다.
|
||||
"""
|
||||
if output.is_symlink() or (output.exists() and not output.is_dir()):
|
||||
raise ValueError("현장음 출력은 symlink가 아닌 디렉터리여야 합니다.")
|
||||
if not output.exists():
|
||||
os.rename(staged, output)
|
||||
return
|
||||
libc = ctypes.CDLL(None, use_errno=True)
|
||||
exchange = getattr(libc, "renameat2", None)
|
||||
if exchange is None:
|
||||
raise OSError("renameat2 원자 교환을 지원하지 않습니다; 이전 디렉터리 보존")
|
||||
exchange.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint]
|
||||
exchange.restype = ctypes.c_int
|
||||
if exchange(-100, os.fsencode(staged), -100, os.fsencode(output), 2) != 0:
|
||||
raise OSError(ctypes.get_errno(), "원자 교환 실패; 이전 디렉터리 보존")
|
||||
|
||||
|
||||
def generate(plan, source_directory, output, *, converter=None):
|
||||
"""모든 원본과 파생본/manifest/notice를 검증한 뒤 디렉터리 전체를 공개한다."""
|
||||
output = Path(output)
|
||||
if output.is_symlink() or (output.exists() and not output.is_dir()):
|
||||
raise ValueError("현장음 출력은 symlink가 아닌 디렉터리여야 합니다.")
|
||||
verify_sources(plan, source_directory)
|
||||
converter = converter or convert
|
||||
ffmpeg = subprocess.check_output(["ffmpeg", "-version"], text=True).splitlines()[0]
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
# 같은 부모 아래 만들어 EXDEV를 피한다. 기존 파일을 overlay하지 않으므로 stale 파일도 사라진다.
|
||||
working = Path(tempfile.mkdtemp(prefix=".ambience-stage-", dir=output.parent))
|
||||
ready = working / "ready"
|
||||
ready.mkdir()
|
||||
files = []
|
||||
try:
|
||||
for asset in plan["assets"]:
|
||||
environment = asset["environmentId"]
|
||||
name = environment + ".wav"
|
||||
info = converter(source_path(source_directory, environment), ready / name, asset["selectedSegmentStartSeconds"])
|
||||
entry = dict(asset)
|
||||
entry.update({"file": name, "sourceSha256": asset["expectedSourceSha256"],
|
||||
"outputLicense": asset["license"], "verificationDateUtc": "2026-10-14", **info})
|
||||
files.append(entry)
|
||||
manifest = {"version": "0.2.3", "generatedAtUtc": datetime.now(timezone.utc).isoformat(),
|
||||
"toolVersions": {"ffmpeg": ffmpeg}, "sampleRate": SAMPLE_RATE,
|
||||
"channels": 1, "encoding": "PCM16", "files": files}
|
||||
(ready / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n")
|
||||
(ready / "AMBIENCE_NOTICES.md").write_text(render_notice(files))
|
||||
validate_bundle(ready, plan)
|
||||
atomic_publish(ready, output)
|
||||
return manifest
|
||||
finally:
|
||||
# commit 뒤 정리 실패를 발행 실패로 오인하지 않는다. SIGKILL은 숨은 stage를 남길 수 있으나
|
||||
# 공개 이름은 syscall 전의 전체 이전 트리 또는 syscall 후의 전체 새 트리다.
|
||||
shutil.rmtree(working, ignore_errors=True)
|
||||
|
||||
|
||||
def main():
|
||||
plan = json.loads((ROOT / ".agent/AMBIENCE-SOURCES-v0.2.3.json").read_text())
|
||||
manifest = generate(plan, Path("/tmp/findword-ambience-sources"), ROOT / "public/ambience")
|
||||
print(json.dumps({"files": len(manifest["files"]), "bytes": sum(f["bytes"] for f in manifest["files"])}, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+120
-2
@@ -1,7 +1,8 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { ENVIRONMENTS, PATTERNS, WORDS } from "../src/content.ts";
|
||||
|
||||
// 빌드가 성공해도 public 파일이 빠지면 게임은 실행 중 실패한다. 따라서 배포
|
||||
// 폴더 자체의 파일 해시를 원본 manifest와 비교하는 계약을 빌드 마지막에 둔다.
|
||||
@@ -36,11 +37,128 @@ export function verifyBuild(directory) {
|
||||
);
|
||||
if (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);
|
||||
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}`);
|
||||
// 다른 항목에 같은 라이선스 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}`);
|
||||
}
|
||||
} catch (cause) {
|
||||
throw new Error("현장음 자산/manifest/notice 누락 또는 불일치", { cause });
|
||||
}
|
||||
return manifest.files.length;
|
||||
}
|
||||
if (
|
||||
@@ -48,6 +166,6 @@ if (
|
||||
import.meta.url === pathToFileURL(resolve(process.argv[1])).href
|
||||
) {
|
||||
console.log(
|
||||
`빌드 음성 자산 ${verifyBuild("dist")}개 및 런타임 notice 전문 확인`,
|
||||
`빌드 음성 자산 ${verifyBuild("dist")}개 + 현장음 8개 및 런타임/현장음 notice 확인`,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user