146 lines
5.7 KiB
TypeScript
146 lines
5.7 KiB
TypeScript
import {
|
|
SEQUENCER_CODEC_PROBE_SCHEMA,
|
|
parseSequencerCodecProbeRequest,
|
|
type SequencerCodecProbeBackend,
|
|
type SequencerCodecProbeBlockReason,
|
|
type SequencerCodecProbeRequestIR,
|
|
type SequencerCodecProbeResultIR,
|
|
} from "../../../protocol/sequencer";
|
|
|
|
async function sha256(data: ArrayBuffer): Promise<string> {
|
|
const digest = await crypto.subtle.digest("SHA-256", data);
|
|
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
}
|
|
|
|
function blocked(
|
|
request: SequencerCodecProbeRequestIR,
|
|
backend: SequencerCodecProbeBackend | null,
|
|
reason: SequencerCodecProbeBlockReason,
|
|
): SequencerCodecProbeResultIR {
|
|
return { ...request, status: "BLOCKED", backend, reason, decoded: null };
|
|
}
|
|
|
|
function waitForMedia(video: HTMLVideoElement, eventName: "loadeddata" | "seeked", timeoutMs: number): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
const timeout = window.setTimeout(() => finish(new Error("timeout")), timeoutMs);
|
|
const finish = (error?: Error): void => {
|
|
window.clearTimeout(timeout);
|
|
video.removeEventListener(eventName, ready);
|
|
video.removeEventListener("error", failed);
|
|
if (error) reject(error); else resolve();
|
|
};
|
|
const ready = (): void => finish();
|
|
const failed = (): void => finish(new Error("decode"));
|
|
video.addEventListener(eventName, ready, { once: true });
|
|
video.addEventListener("error", failed, { once: true });
|
|
});
|
|
}
|
|
|
|
async function probeImage(request: SequencerCodecProbeRequestIR, data: ArrayBuffer): Promise<SequencerCodecProbeResultIR> {
|
|
if (typeof createImageBitmap !== "function") return blocked(request, null, "RUNTIME_UNAVAILABLE");
|
|
try {
|
|
const bitmap = await createImageBitmap(new Blob([data], { type: request.mimeType }));
|
|
try {
|
|
if (bitmap.width < 1 || bitmap.height < 1) return blocked(request, "IMAGE_BITMAP", "DECODE_FAILED");
|
|
return { ...request, status: "READY", backend: "IMAGE_BITMAP", reason: null, decoded: { width: bitmap.width, height: bitmap.height } };
|
|
}
|
|
finally { bitmap.close(); }
|
|
}
|
|
catch { return blocked(request, "IMAGE_BITMAP", "DECODE_FAILED"); }
|
|
}
|
|
|
|
async function probeSound(request: SequencerCodecProbeRequestIR, data: ArrayBuffer): Promise<SequencerCodecProbeResultIR> {
|
|
if (typeof OfflineAudioContext === "undefined") return blocked(request, null, "RUNTIME_UNAVAILABLE");
|
|
const context = new OfflineAudioContext(1, 1, 48_000);
|
|
try {
|
|
const decoded = await context.decodeAudioData(data.slice(0));
|
|
if (decoded.sampleRate < 1 || decoded.numberOfChannels < 1 || decoded.length < 1) return blocked(request, "WEB_AUDIO", "DECODE_FAILED");
|
|
return {
|
|
...request,
|
|
status: "READY",
|
|
backend: "WEB_AUDIO",
|
|
reason: null,
|
|
decoded: { sampleRate: decoded.sampleRate, channels: decoded.numberOfChannels, durationFrames: decoded.length },
|
|
};
|
|
}
|
|
catch { return blocked(request, "WEB_AUDIO", "DECODE_FAILED"); }
|
|
}
|
|
|
|
async function probeMovie(request: SequencerCodecProbeRequestIR, data: ArrayBuffer): Promise<SequencerCodecProbeResultIR> {
|
|
if (typeof document === "undefined" || typeof HTMLMediaElement === "undefined") return blocked(request, null, "RUNTIME_UNAVAILABLE");
|
|
const video = document.createElement("video");
|
|
if (video.canPlayType(request.mimeType) === "") return blocked(request, "HTML_MEDIA", "MIME_UNSUPPORTED");
|
|
const url = URL.createObjectURL(new Blob([data], { type: request.mimeType }));
|
|
video.preload = "auto";
|
|
video.muted = true;
|
|
video.playsInline = true;
|
|
video.style.display = "none";
|
|
document.body.append(video);
|
|
try {
|
|
video.src = url;
|
|
video.load();
|
|
await waitForMedia(video, "loadeddata", 10_000);
|
|
if (!Number.isFinite(video.duration) || video.duration <= 0 || video.videoWidth < 1 || video.videoHeight < 1) {
|
|
return blocked(request, "HTML_MEDIA", "DECODE_FAILED");
|
|
}
|
|
const target = Math.min(video.duration / 2, Math.max(0, video.duration - 0.001));
|
|
if (target > 0) {
|
|
video.currentTime = target;
|
|
await waitForMedia(video, "seeked", 10_000);
|
|
}
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = 1;
|
|
canvas.height = 1;
|
|
const context = canvas.getContext("2d", { willReadFrequently: true });
|
|
if (!context) return blocked(request, "HTML_MEDIA", "RUNTIME_UNAVAILABLE");
|
|
context.drawImage(video, 0, 0, 1, 1);
|
|
context.getImageData(0, 0, 1, 1);
|
|
return {
|
|
...request,
|
|
status: "READY",
|
|
backend: "HTML_MEDIA",
|
|
reason: null,
|
|
decoded: {
|
|
width: video.videoWidth,
|
|
height: video.videoHeight,
|
|
durationMicros: Math.max(1, Math.round(video.duration * 1_000_000)),
|
|
},
|
|
};
|
|
}
|
|
catch { return blocked(request, "HTML_MEDIA", "DECODE_FAILED"); }
|
|
finally {
|
|
video.removeAttribute("src");
|
|
video.load();
|
|
video.remove();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
}
|
|
|
|
export async function probeSequencerCodec(
|
|
requestValue: unknown,
|
|
source: ArrayBuffer,
|
|
): Promise<SequencerCodecProbeResultIR> {
|
|
const request = parseSequencerCodecProbeRequest(requestValue);
|
|
if (!(source instanceof ArrayBuffer) || source.byteLength !== request.byteLength || await sha256(source) !== request.sourceSha256) {
|
|
return blocked(request, null, "SOURCE_IDENTITY_MISMATCH");
|
|
}
|
|
if (request.stripType === "IMAGE") return probeImage(request, source);
|
|
if (request.stripType === "SOUND") return probeSound(request, source);
|
|
return probeMovie(request, source);
|
|
}
|
|
|
|
export function createSequencerCodecProbeRequest(
|
|
stripType: SequencerCodecProbeRequestIR["stripType"],
|
|
mimeType: string,
|
|
byteLength: number,
|
|
sourceSha256: string,
|
|
): SequencerCodecProbeRequestIR {
|
|
return parseSequencerCodecProbeRequest({
|
|
schemaVersion: SEQUENCER_CODEC_PROBE_SCHEMA,
|
|
stripType,
|
|
mimeType,
|
|
byteLength,
|
|
sourceSha256,
|
|
});
|
|
}
|