Files
micarrsim/apps/acoustic-camera/asset-runtime.js
T

274 lines
8.9 KiB
JavaScript

import { decodeAudioFile, MAX_DECODED_AUDIO_BYTES } from "/packages/audio-io/browser-audio.js";
import { sha256File } from "/packages/contracts/io.js";
const MAX_FILE_BYTES = 128 * 1024 * 1024;
const MAX_BOUND_FILE_BYTES = 512 * 1024 * 1024;
function basename(path) {
return String(path).split(/[\\/]/).pop();
}
function kindForFile(file) {
if (file.type.startsWith("image/") || /\.(?:png|jpe?g|webp|gif|bmp|svg)$/i.test(file.name)) return "image";
if (file.type.startsWith("audio/") || /\.(?:wav|flac|mp3|ogg|m4a|aac)$/i.test(file.name)) return "audio";
return null;
}
function referencedAssetIds(scene) {
const ids = new Set();
if (scene.image?.assetId) ids.add(scene.image.assetId);
for (const entry of [...scene.sources, ...(scene.ambient ?? [])]) {
if (entry.signal?.type === "asset") ids.add(entry.signal.assetId);
}
return ids;
}
function audioAssetIds(scene) {
const ids = new Set();
for (const entry of [...scene.sources, ...(scene.ambient ?? [])]) {
if (entry.signal?.type === "asset") ids.add(entry.signal.assetId);
}
return ids;
}
async function loadImageElement(file) {
const url = URL.createObjectURL(file);
const image = new Image();
image.decoding = "async";
try {
await new Promise((resolve, reject) => {
image.addEventListener("load", resolve, { once: true });
image.addEventListener("error", () => reject(new Error(`${file.name} 이미지를 decode하지 못했습니다.`)), {
once: true,
});
image.src = url;
});
return { image, url };
} catch (error) {
URL.revokeObjectURL(url);
throw error;
}
}
function waitForDecode(decodePromise, signal) {
if (!signal) return decodePromise;
if (signal.aborted) return Promise.reject(new DOMException("오디오 decode가 취소됐습니다.", "AbortError"));
return new Promise((resolve, reject) => {
const onAbort = () => reject(new DOMException("오디오 decode가 취소됐습니다.", "AbortError"));
signal.addEventListener("abort", onAbort, { once: true });
decodePromise.then(
(value) => {
signal.removeEventListener("abort", onAbort);
resolve(value);
},
(error) => {
signal.removeEventListener("abort", onAbort);
reject(error);
},
);
});
}
export function createCameraAssetRuntime(onChange = () => {}) {
const files = new Map();
const errors = new Map();
let imageElement = null;
let imageUrl = null;
let generation = 0;
function revokeImage() {
if (imageUrl) URL.revokeObjectURL(imageUrl);
imageUrl = null;
imageElement = null;
}
function reset() {
generation += 1;
files.clear();
errors.clear();
revokeImage();
onChange();
}
function totalBoundBytes(exceptIds = new Set()) {
let total = 0;
const countedFiles = new Set();
for (const [assetId, file] of files) {
if (!exceptIds.has(assetId) && !countedFiles.has(file)) {
total += file.size;
countedFiles.add(file);
}
}
return total;
}
async function refreshImage(scene, expectedGeneration) {
const imageAssetId = scene.image?.assetId;
if (!imageAssetId || !files.has(imageAssetId)) {
revokeImage();
return;
}
const file = files.get(imageAssetId);
const loaded = await loadImageElement(file);
if (generation !== expectedGeneration) {
URL.revokeObjectURL(loaded.url);
return;
}
revokeImage();
imageElement = loaded.image;
imageUrl = loaded.url;
}
async function bindFiles(selectedFiles, scene) {
const currentGeneration = ++generation;
const descriptors = scene.assets ?? [];
const bound = [];
const rejected = [];
for (const file of selectedFiles) {
try {
if (file.size > MAX_FILE_BYTES) {
throw new Error(`${file.name} 크기가 128 MiB 파일 한도를 넘습니다.`);
}
const kind = kindForFile(file);
if (!kind) throw new Error(`${file.name}의 image/audio 종류를 판별할 수 없습니다.`);
const hash = await sha256File(file);
if (generation !== currentGeneration) return { bound, rejected, stale: true };
const candidates = descriptors.filter((descriptor) => descriptor.kind === kind);
let matches = candidates.filter((descriptor) => descriptor.sha256 === hash);
if (matches.length === 0) {
matches = candidates.filter(
(descriptor) => !descriptor.sha256 && basename(descriptor.fileName) === file.name,
);
}
if (matches.length === 0) {
const conflict = candidates.some(
(descriptor) => basename(descriptor.fileName) === file.name && descriptor.sha256 !== hash,
);
throw new Error(
conflict
? `${file.name}의 SHA-256이 manifest와 다릅니다.`
: `${file.name}에 대응하는 ${kind} asset descriptor가 없습니다.`,
);
}
const replacingIds = new Set(matches.map((descriptor) => descriptor.id));
const nextTotal = totalBoundBytes(replacingIds) + file.size;
if (nextTotal > MAX_BOUND_FILE_BYTES) {
throw new Error(`연결 파일 합계가 512 MiB 한도를 넘습니다.`);
}
for (const descriptor of matches) {
descriptor.sha256 ??= hash;
files.set(descriptor.id, file);
errors.delete(descriptor.id);
bound.push(descriptor.id);
}
} catch (error) {
rejected.push({ fileName: file.name, message: error instanceof Error ? error.message : String(error) });
}
}
try {
await refreshImage(scene, currentGeneration);
} catch (error) {
if (scene.image?.assetId) errors.set(scene.image.assetId, error.message);
rejected.push({ fileName: files.get(scene.image?.assetId)?.name ?? "image", message: error.message });
}
if (generation === currentGeneration) onChange();
return { bound: [...new Set(bound)], rejected, stale: generation !== currentGeneration };
}
function statuses(scene) {
const referenced = referencedAssetIds(scene);
return (scene.assets ?? [])
.filter((descriptor) => referenced.has(descriptor.id))
.map((descriptor) => ({
descriptor,
file: files.get(descriptor.id) ?? null,
error: errors.get(descriptor.id) ?? null,
ready: files.has(descriptor.id) && !errors.has(descriptor.id),
}));
}
function missingAssetIds(scene) {
return statuses(scene).filter((status) => !status.ready).map(({ descriptor }) => descriptor.id);
}
function missingAudioAssetIds(scene) {
const audioIds = audioAssetIds(scene);
return statuses(scene)
.filter(
(status) =>
audioIds.has(status.descriptor.id) &&
!status.ready,
)
.map(({ descriptor }) => descriptor.id);
}
async function decodeAudioAssets(scene, signal, onProgress = () => {}) {
const ids = [...audioAssetIds(scene)];
const entries = [...scene.sources, ...(scene.ambient ?? [])];
const decoded = new Map();
const decodedByFile = new Map();
let retainedBytes = 0;
for (let index = 0; index < ids.length; index += 1) {
const assetId = ids[index];
const file = files.get(assetId);
if (!file) throw new Error(`오디오 원본 asset을 연결하세요: ${assetId}`);
onProgress({ completed: index, total: ids.length, assetId, fileName: file.name });
let audio = decodedByFile.get(file);
if (!audio) {
const remaining = MAX_DECODED_AUDIO_BYTES - retainedBytes;
audio = await waitForDecode(
decodeAudioFile(file, scene.sampleRate, remaining, signal),
signal,
);
decodedByFile.set(file, audio);
retainedBytes += audio.channels.reduce((sum, channel) => sum + channel.byteLength, 0);
}
for (const entry of entries) {
if (entry.signal?.type !== "asset" || entry.signal.assetId !== assetId) continue;
const channel = entry.signal.channel ?? 0;
if (!audio.channels[channel]) {
throw new Error(
`${assetId}은 ${audio.channels.length}채널 오디오이므로 요청한 channel ${channel}을 사용할 수 없습니다.`,
);
}
}
decoded.set(assetId, audio);
}
onProgress({ completed: ids.length, total: ids.length });
return decoded;
}
function transferListForAudio(assetBuffers) {
const transfer = [];
const seen = new Set();
for (const audio of assetBuffers.values()) {
for (const channel of audio.channels) {
if (!seen.has(channel.buffer)) {
seen.add(channel.buffer);
transfer.push(channel.buffer);
}
}
}
return transfer;
}
return {
bindFiles,
decodeAudioAssets,
get imageElement() {
return imageElement;
},
missingAssetIds,
missingAudioAssetIds,
reset,
statuses,
transferListForAudio,
dispose() {
generation += 1;
revokeImage();
files.clear();
errors.clear();
},
};
}