feat(dictionary): add Hachidori backend support

- Add backend selection, setup gating, Anki integration, and external host support
- Add launcher flags, documentation, packaging, and focused tests
- Open on-demand overlay modals on the first attempt
This commit is contained in:
2026-09-22 00:21:19 -07:00
parent 1508863dbb
commit d9fdc7ef6d
446 changed files with 109060 additions and 244 deletions
+135
View File
@@ -0,0 +1,135 @@
// SPDX-License-Identifier: GPL-3.0-or-later
import {
CAPTURE_SAMPLE_RATE,
MAX_ANIMATED_AVIF_BYTES,
} from "./media-limits.js";
export { MAX_ANIMATED_AVIF_BYTES };
export const AVIF_TIMESCALE = CAPTURE_SAMPLE_RATE;
function encoderError(module, handle) {
return module.UTF8ToString(module._hda_last_error(handle)) || "Animated AVIF encoding failed.";
}
export function frameDurations(frames, endMs, timescale = AVIF_TIMESCALE) {
if (!Array.isArray(frames) || !frames.length || !Number.isFinite(endMs)
|| !Number.isSafeInteger(timescale) || timescale < 1) {
throw new Error("AVIF frame timing is invalid");
}
for (let index = 0; index < frames.length; index += 1) {
const frame = frames[index];
if (!Number.isFinite(frame.timestampMs)
|| (index > 0 && frame.timestampMs <= frames[index - 1].timestampMs)) {
throw new Error("AVIF frame timestamps must increase");
}
}
const startMs = frames[0].timestampMs;
let previousTick = 0;
return frames.map((frame, index) => {
const nextMs = index + 1 < frames.length ? frames[index + 1].timestampMs : endMs;
if (nextMs <= frame.timestampMs) throw new Error("AVIF frame duration is empty");
// Quantize cumulative boundaries, so rounding never drifts across frames.
// The final ceil matches the WAV's exact number of PCM samples.
const nextTick = index + 1 === frames.length
? Math.ceil((nextMs - startMs) * timescale / 1000)
: Math.round((nextMs - startMs) * timescale / 1000);
const duration = nextTick - previousTick;
if (duration < 1) throw new Error("AVIF frame duration is below the timebase precision");
previousTick = nextTick;
return duration;
});
}
export function createAvifSequenceEncoder(module, {
width,
height,
timescale = AVIF_TIMESCALE,
quality = 55,
speed = 8,
} = {}) {
const handle = module._hda_create(width, height, timescale, quality, speed);
if (!handle) throw new Error(encoderError(module, 0));
let finished = false;
return {
add(rgba, duration) {
if (finished) throw new Error("AVIF sequence is already finished");
if (!(rgba instanceof Uint8Array) || rgba.byteLength !== width * height * 4) {
throw new Error("AVIF RGBA frame has the wrong size");
}
const pointer = module._malloc(rgba.byteLength);
if (!pointer) throw new Error("Could not allocate AVIF frame memory.");
try {
module.HEAPU8.set(rgba, pointer);
if (!module._hda_add_rgba(handle, pointer, rgba.byteLength, duration)) {
throw new Error(encoderError(module, handle));
}
} finally {
module._free(pointer);
}
},
finish() {
if (finished) throw new Error("AVIF sequence is already finished");
finished = true;
if (!module._hda_finish(handle)) throw new Error(encoderError(module, handle));
const size = module._hda_output_size(handle);
if (!size || size > MAX_ANIMATED_AVIF_BYTES) throw new Error("Animated AVIF exceeds its output limit.");
const pointer = module._hda_output(handle);
return module.HEAPU8.slice(pointer, pointer + size);
},
destroy() {
module._hda_destroy(handle);
},
};
}
export async function encodeJpegSequence(module, frames, {
endMs,
quality = 55,
speed = 8,
createBitmap = globalThis.createImageBitmap?.bind(globalThis),
createCanvas = (width, height) => new OffscreenCanvas(width, height),
onProgress = () => {},
} = {}) {
if (!Array.isArray(frames) || !frames.length || typeof createBitmap !== "function") {
throw new Error("Captured video frames cannot be decoded.");
}
const [{ width, height }] = frames;
if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height)) throw new Error("Captured frame dimensions are invalid.");
if (frames.some(frame => frame.width !== width || frame.height !== height)) {
throw new Error("Captured frame dimensions changed during the selected interval.");
}
const durations = frameDurations(frames, endMs);
if (frames.length === 1 && durations[0] < 2) {
throw new Error("AVIF sequence duration is below the timebase precision");
}
const canvas = createCanvas(width, height);
const context = canvas.getContext("2d", { alpha: false, willReadFrequently: true });
if (!context) throw new Error("Could not create the AVIF frame decoder canvas.");
const encoder = createAvifSequenceEncoder(module, { width, height, quality, speed });
try {
for (let index = 0; index < frames.length; index += 1) {
const frame = frames[index];
const bitmap = await createBitmap(new Blob([frame.data], { type: "image/jpeg" }));
try {
context.drawImage(bitmap, 0, 0, width, height);
const rgba = context.getImageData(0, 0, width, height).data;
const pixels = new Uint8Array(rgba.buffer, rgba.byteOffset, rgba.byteLength);
if (frames.length === 1) {
// libavif writes one image as a still AVIF without sequence timing.
// Repeat its pixels while preserving the exact selected sample count.
const firstDuration = Math.floor(durations[index] / 2);
encoder.add(pixels, firstDuration);
encoder.add(pixels, durations[index] - firstDuration);
} else {
encoder.add(pixels, durations[index]);
}
} finally {
bitmap.close();
}
onProgress(index + 1, frames.length);
}
return encoder.finish();
} finally {
encoder.destroy();
}
}