274 lines
8.9 KiB
JavaScript
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();
|
|
},
|
|
};
|
|
}
|