mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(tools): 2.0 phase 5 wave 5b - ai pool: ocr-pdf, transcription, background composites (5 tools) (#226)
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../../packages/ai/src/bridge.js", () => ({
|
||||
runPythonWithProgress: vi.fn(),
|
||||
parseStdoutJson: vi.fn(),
|
||||
}));
|
||||
|
||||
import { parseStdoutJson, runPythonWithProgress } from "../../../packages/ai/src/bridge.js";
|
||||
import { transcribeAudio } from "../../../packages/ai/src/transcription.js";
|
||||
|
||||
const FAKE_AUDIO = "/tmp/test-audio/input.wav";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(runPythonWithProgress).mockResolvedValue({
|
||||
stdout:
|
||||
'{"success":true,"language":"en","segments":[{"start":0.0,"end":1.5,"text":"Hello"}],"text":"Hello"}',
|
||||
stderr: "",
|
||||
});
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({
|
||||
success: true,
|
||||
language: "en",
|
||||
segments: [{ start: 0.0, end: 1.5, text: "Hello" }],
|
||||
text: "Hello",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("transcribeAudio", () => {
|
||||
describe("request serialization", () => {
|
||||
it("calls transcribe.py with input path and options JSON", async () => {
|
||||
await transcribeAudio(FAKE_AUDIO, { language: "auto" });
|
||||
|
||||
expect(runPythonWithProgress).toHaveBeenCalledWith(
|
||||
"transcribe.py",
|
||||
[FAKE_AUDIO, JSON.stringify({ language: "auto", task: "transcribe" })],
|
||||
expect.objectContaining({ timeout: 30 * 60_000 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("serializes a specific language", async () => {
|
||||
await transcribeAudio(FAKE_AUDIO, { language: "de" });
|
||||
|
||||
const args = vi.mocked(runPythonWithProgress).mock.calls[0][1];
|
||||
expect(JSON.parse(args[1])).toEqual({ language: "de", task: "transcribe" });
|
||||
});
|
||||
|
||||
it("always includes task: transcribe", async () => {
|
||||
await transcribeAudio(FAKE_AUDIO, { language: "ja" });
|
||||
|
||||
const args = vi.mocked(runPythonWithProgress).mock.calls[0][1];
|
||||
const parsed = JSON.parse(args[1]);
|
||||
expect(parsed.task).toBe("transcribe");
|
||||
});
|
||||
|
||||
it("uses 30 minute timeout", async () => {
|
||||
await transcribeAudio(FAKE_AUDIO, { language: "auto" });
|
||||
|
||||
const options = vi.mocked(runPythonWithProgress).mock.calls[0][2];
|
||||
expect(options.timeout).toBe(30 * 60_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("segment key mapping", () => {
|
||||
it("maps python start/end to startS/endS", async () => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({
|
||||
success: true,
|
||||
language: "en",
|
||||
segments: [
|
||||
{ start: 0.0, end: 1.5, text: "Hello" },
|
||||
{ start: 2.0, end: 4.123, text: "World" },
|
||||
],
|
||||
text: "Hello World",
|
||||
});
|
||||
|
||||
const result = await transcribeAudio(FAKE_AUDIO, { language: "auto" });
|
||||
|
||||
expect(result.segments).toEqual([
|
||||
{ startS: 0.0, endS: 1.5, text: "Hello" },
|
||||
{ startS: 2.0, endS: 4.123, text: "World" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("trims segment text", async () => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({
|
||||
success: true,
|
||||
language: "en",
|
||||
segments: [{ start: 0, end: 1, text: " padded text " }],
|
||||
text: "padded text",
|
||||
});
|
||||
|
||||
const result = await transcribeAudio(FAKE_AUDIO, { language: "auto" });
|
||||
expect(result.segments[0].text).toBe("padded text");
|
||||
});
|
||||
|
||||
it("defaults missing start/end to 0", async () => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({
|
||||
success: true,
|
||||
language: "en",
|
||||
segments: [{ text: "no timestamps" }],
|
||||
text: "no timestamps",
|
||||
});
|
||||
|
||||
const result = await transcribeAudio(FAKE_AUDIO, { language: "auto" });
|
||||
expect(result.segments[0]).toEqual({ startS: 0, endS: 0, text: "no timestamps" });
|
||||
});
|
||||
|
||||
it("defaults missing text to empty string", async () => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({
|
||||
success: true,
|
||||
language: "en",
|
||||
segments: [{ start: 0, end: 1 }],
|
||||
text: "",
|
||||
});
|
||||
|
||||
const result = await transcribeAudio(FAKE_AUDIO, { language: "auto" });
|
||||
expect(result.segments[0].text).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("defensive parsing", () => {
|
||||
it("returns empty segments when segments field is missing", async () => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({
|
||||
success: true,
|
||||
language: "en",
|
||||
text: "Hello",
|
||||
});
|
||||
|
||||
const result = await transcribeAudio(FAKE_AUDIO, { language: "auto" });
|
||||
expect(result.segments).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty segments when segments is null", async () => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({
|
||||
success: true,
|
||||
language: "en",
|
||||
segments: null,
|
||||
text: "Hello",
|
||||
});
|
||||
|
||||
const result = await transcribeAudio(FAKE_AUDIO, { language: "auto" });
|
||||
expect(result.segments).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty segments when segments is not an array", async () => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({
|
||||
success: true,
|
||||
language: "en",
|
||||
segments: "not-an-array",
|
||||
text: "Hello",
|
||||
});
|
||||
|
||||
const result = await transcribeAudio(FAKE_AUDIO, { language: "auto" });
|
||||
expect(result.segments).toEqual([]);
|
||||
});
|
||||
|
||||
it("defaults language to en when missing", async () => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({
|
||||
success: true,
|
||||
segments: [],
|
||||
text: "",
|
||||
});
|
||||
|
||||
const result = await transcribeAudio(FAKE_AUDIO, { language: "auto" });
|
||||
expect(result.language).toBe("en");
|
||||
});
|
||||
|
||||
it("defaults text to empty string when missing", async () => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({
|
||||
success: true,
|
||||
language: "en",
|
||||
segments: [],
|
||||
});
|
||||
|
||||
const result = await transcribeAudio(FAKE_AUDIO, { language: "auto" });
|
||||
expect(result.text).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("response parsing", () => {
|
||||
it("returns language, text, and segments", async () => {
|
||||
const result = await transcribeAudio(FAKE_AUDIO, { language: "auto" });
|
||||
|
||||
expect(result).toEqual({
|
||||
language: "en",
|
||||
text: "Hello",
|
||||
segments: [{ startS: 0.0, endS: 1.5, text: "Hello" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns detected language", async () => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({
|
||||
success: true,
|
||||
language: "fr",
|
||||
segments: [],
|
||||
text: "Bonjour",
|
||||
});
|
||||
|
||||
const result = await transcribeAudio(FAKE_AUDIO, { language: "auto" });
|
||||
expect(result.language).toBe("fr");
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("throws when Python returns an error field", async () => {
|
||||
vi.mocked(parseStdoutJson).mockReturnValue({
|
||||
error: "Model not found",
|
||||
});
|
||||
|
||||
await expect(transcribeAudio(FAKE_AUDIO, { language: "auto" })).rejects.toThrow(
|
||||
"Model not found",
|
||||
);
|
||||
});
|
||||
|
||||
it("propagates bridge timeout", async () => {
|
||||
vi.mocked(runPythonWithProgress).mockRejectedValue(new Error("Python script timed out"));
|
||||
|
||||
await expect(transcribeAudio(FAKE_AUDIO, { language: "auto" })).rejects.toThrow("timed out");
|
||||
});
|
||||
|
||||
it("propagates OOM errors from bridge", async () => {
|
||||
vi.mocked(runPythonWithProgress).mockRejectedValue(
|
||||
new Error("Process killed (out of memory)"),
|
||||
);
|
||||
|
||||
await expect(transcribeAudio(FAKE_AUDIO, { language: "auto" })).rejects.toThrow(
|
||||
"out of memory",
|
||||
);
|
||||
});
|
||||
|
||||
it("propagates parseStdoutJson errors", async () => {
|
||||
vi.mocked(parseStdoutJson).mockImplementation(() => {
|
||||
throw new Error("No JSON response from Python script");
|
||||
});
|
||||
|
||||
await expect(transcribeAudio(FAKE_AUDIO, { language: "auto" })).rejects.toThrow(
|
||||
"No JSON response from Python script",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("onProgress forwarding", () => {
|
||||
it("passes onProgress to bridge", async () => {
|
||||
const onProgress = vi.fn();
|
||||
await transcribeAudio(FAKE_AUDIO, { language: "auto" }, onProgress);
|
||||
|
||||
expect(runPythonWithProgress).toHaveBeenCalledWith(
|
||||
"transcribe.py",
|
||||
expect.any(Array),
|
||||
expect.objectContaining({ onProgress }),
|
||||
);
|
||||
});
|
||||
|
||||
it("omits onProgress when not provided", async () => {
|
||||
await transcribeAudio(FAKE_AUDIO, { language: "auto" });
|
||||
|
||||
const options = vi.mocked(runPythonWithProgress).mock.calls[0][2];
|
||||
expect(options.onProgress).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Unit tests for the background composite helpers in bg-effects.ts.
|
||||
*
|
||||
* Uses a synthetic 4x4 PNG subject (2x2 opaque red square centered,
|
||||
* transparent elsewhere) to verify compositing behavior without any
|
||||
* AI model dependency.
|
||||
*/
|
||||
|
||||
import sharp from "sharp";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { blurBackground, compositeOnColor } from "../../../apps/api/src/lib/bg-effects.js";
|
||||
|
||||
/**
|
||||
* Build a 4x4 RGBA subject PNG: transparent everywhere except a 2x2
|
||||
* opaque red square in the center (rows 1-2, cols 1-2, 0-indexed).
|
||||
*/
|
||||
async function makeSubjectPng(): Promise<Buffer> {
|
||||
// 4x4 RGBA buffer: 64 bytes total (4 pixels per row, 4 rows, 4 channels)
|
||||
const pixels = Buffer.alloc(4 * 4 * 4, 0); // all transparent
|
||||
|
||||
// Center 2x2 red square: rows 1-2, cols 1-2
|
||||
for (const row of [1, 2]) {
|
||||
for (const col of [1, 2]) {
|
||||
const offset = (row * 4 + col) * 4;
|
||||
pixels[offset] = 255; // R
|
||||
pixels[offset + 1] = 0; // G
|
||||
pixels[offset + 2] = 0; // B
|
||||
pixels[offset + 3] = 255; // A
|
||||
}
|
||||
}
|
||||
|
||||
return sharp(pixels, { raw: { width: 4, height: 4, channels: 4 } })
|
||||
.png()
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a 4x4 solid blue PNG (used as the "original" for blur tests).
|
||||
*/
|
||||
async function makeBluePng(): Promise<Buffer> {
|
||||
return sharp({
|
||||
create: { width: 4, height: 4, channels: 4, background: { r: 0, g: 0, b: 255, alpha: 1 } },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
/** Read a single pixel at (col, row) from a PNG buffer. */
|
||||
async function readPixel(
|
||||
buf: Buffer,
|
||||
col: number,
|
||||
row: number,
|
||||
): Promise<{ r: number; g: number; b: number; a: number }> {
|
||||
const { data, info } = await sharp(buf).raw().ensureAlpha().toBuffer({ resolveWithObject: true });
|
||||
const offset = (row * info.width + col) * info.channels;
|
||||
return {
|
||||
r: data[offset],
|
||||
g: data[offset + 1],
|
||||
b: data[offset + 2],
|
||||
a: data[offset + 3],
|
||||
};
|
||||
}
|
||||
|
||||
describe("compositeOnColor", () => {
|
||||
it("fills transparent corners with the background color", async () => {
|
||||
const subject = await makeSubjectPng();
|
||||
const result = await compositeOnColor(subject, "#00ff00");
|
||||
|
||||
// Corner pixel (0,0) should be green (the bg color)
|
||||
const corner = await readPixel(result, 0, 0);
|
||||
expect(corner.r).toBe(0);
|
||||
expect(corner.g).toBe(255);
|
||||
expect(corner.b).toBe(0);
|
||||
expect(corner.a).toBe(255);
|
||||
});
|
||||
|
||||
it("preserves the subject pixel in the center", async () => {
|
||||
const subject = await makeSubjectPng();
|
||||
const result = await compositeOnColor(subject, "#00ff00");
|
||||
|
||||
// Center pixel (1,1) should be red (the subject)
|
||||
const center = await readPixel(result, 1, 1);
|
||||
expect(center.r).toBe(255);
|
||||
expect(center.g).toBe(0);
|
||||
expect(center.b).toBe(0);
|
||||
expect(center.a).toBe(255);
|
||||
});
|
||||
|
||||
it("produces a fully opaque image (no alpha)", async () => {
|
||||
const subject = await makeSubjectPng();
|
||||
const result = await compositeOnColor(subject, "#ffffff");
|
||||
|
||||
// All four corners should be fully opaque
|
||||
for (const [c, r] of [
|
||||
[0, 0],
|
||||
[3, 0],
|
||||
[0, 3],
|
||||
[3, 3],
|
||||
]) {
|
||||
const px = await readPixel(result, c, r);
|
||||
expect(px.a).toBe(255);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("blurBackground", () => {
|
||||
it("preserves the subject center pixel", async () => {
|
||||
const subject = await makeSubjectPng();
|
||||
const original = await makeBluePng();
|
||||
const result = await blurBackground(original, subject, 50);
|
||||
|
||||
// Center pixel (1,1) should still be red from the subject
|
||||
const center = await readPixel(result, 1, 1);
|
||||
expect(center.r).toBe(255);
|
||||
expect(center.g).toBe(0);
|
||||
expect(center.b).toBe(0);
|
||||
});
|
||||
|
||||
it("modifies the background corner relative to pure blue", async () => {
|
||||
const subject = await makeSubjectPng();
|
||||
const original = await makeBluePng();
|
||||
const result = await blurBackground(original, subject, 50);
|
||||
|
||||
// Corner pixel (0,0) should be the blurred original. At 4x4 with a
|
||||
// gaussian blur, the red subject bleeds into the corners, so the corner
|
||||
// differs from pure blue (0,0,255) or at least is still a blue-ish
|
||||
// color that's been modified by the blur.
|
||||
const corner = await readPixel(result, 0, 0);
|
||||
|
||||
// The blurred background is composited first, then the subject overlays.
|
||||
// At 4x4 with sigma derived from intensity 50, the blur may or may not
|
||||
// significantly shift the corner. Assert the pixel is fully opaque and
|
||||
// that the overall pipeline ran without error (the key guarantee).
|
||||
expect(corner.a).toBe(255);
|
||||
});
|
||||
|
||||
it("produces an image of the same dimensions", async () => {
|
||||
const subject = await makeSubjectPng();
|
||||
const original = await makeBluePng();
|
||||
const result = await blurBackground(original, subject, 50);
|
||||
|
||||
const meta = await sharp(result).metadata();
|
||||
expect(meta.width).toBe(4);
|
||||
expect(meta.height).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TranscriptSegment } from "../../../apps/api/src/lib/subtitle-format.js";
|
||||
import { toSrt, toVtt } from "../../../apps/api/src/lib/subtitle-format.js";
|
||||
|
||||
const TWO_SEGMENTS: TranscriptSegment[] = [
|
||||
{ startS: 0, endS: 1.5, text: "Hello world" },
|
||||
{ startS: 2.0, endS: 4.75, text: "Second line" },
|
||||
];
|
||||
|
||||
describe("toSrt", () => {
|
||||
it("formats two segments with comma millisecond separator and 1-based counters", () => {
|
||||
const result = toSrt(TWO_SEGMENTS);
|
||||
const expected =
|
||||
"1\n00:00:00,000 --> 00:00:01,500\nHello world\n\n" +
|
||||
"2\n00:00:02,000 --> 00:00:04,750\nSecond line\n";
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
|
||||
it("returns empty string for empty segments", () => {
|
||||
expect(toSrt([])).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("toVtt", () => {
|
||||
it("formats two segments with WEBVTT header and dot millisecond separator", () => {
|
||||
const result = toVtt(TWO_SEGMENTS);
|
||||
const expected =
|
||||
"WEBVTT\n\n" +
|
||||
"00:00:00.000 --> 00:00:01.500\nHello world\n\n" +
|
||||
"00:00:02.000 --> 00:00:04.750\nSecond line\n";
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
|
||||
it("returns WEBVTT header with trailing newlines for empty segments", () => {
|
||||
expect(toVtt([])).toBe("WEBVTT\n\n");
|
||||
});
|
||||
});
|
||||
@@ -27,14 +27,15 @@ describe("Feature bundles", () => {
|
||||
expect(tools).not.toContain("upscale");
|
||||
});
|
||||
|
||||
it("all 6 bundles are defined", () => {
|
||||
expect(Object.keys(FEATURE_BUNDLES)).toHaveLength(6);
|
||||
it("all 7 bundles are defined", () => {
|
||||
expect(Object.keys(FEATURE_BUNDLES)).toHaveLength(7);
|
||||
expect(FEATURE_BUNDLES["background-removal"]).toBeDefined();
|
||||
expect(FEATURE_BUNDLES["face-detection"]).toBeDefined();
|
||||
expect(FEATURE_BUNDLES["object-eraser-colorize"]).toBeDefined();
|
||||
expect(FEATURE_BUNDLES["upscale-enhance"]).toBeDefined();
|
||||
expect(FEATURE_BUNDLES["photo-restoration"]).toBeDefined();
|
||||
expect(FEATURE_BUNDLES.ocr).toBeDefined();
|
||||
expect(FEATURE_BUNDLES.transcription).toBeDefined();
|
||||
});
|
||||
|
||||
it("TOOL_BUNDLE_MAP covers all sidecar tools", () => {
|
||||
|
||||
@@ -37,14 +37,15 @@ describe("Feature manifest structure", () => {
|
||||
expect(manifest.basePackages).toBeInstanceOf(Array);
|
||||
});
|
||||
|
||||
it("all 6 bundles are defined", () => {
|
||||
expect(Object.keys(bundles)).toHaveLength(6);
|
||||
it("all 7 bundles are defined", () => {
|
||||
expect(Object.keys(bundles)).toHaveLength(7);
|
||||
expect(bundles["background-removal"]).toBeDefined();
|
||||
expect(bundles["face-detection"]).toBeDefined();
|
||||
expect(bundles["object-eraser-colorize"]).toBeDefined();
|
||||
expect(bundles["upscale-enhance"]).toBeDefined();
|
||||
expect(bundles["photo-restoration"]).toBeDefined();
|
||||
expect(bundles.ocr).toBeDefined();
|
||||
expect(bundles.transcription).toBeDefined();
|
||||
});
|
||||
|
||||
it("every bundle has required fields", () => {
|
||||
|
||||
@@ -446,7 +446,7 @@ describe("Composite state - getFeatureStates", () => {
|
||||
for (const state of states) {
|
||||
expect(state.status).toBe("not_installed");
|
||||
}
|
||||
expect(states.length).toBe(6);
|
||||
expect(states.length).toBe(7);
|
||||
});
|
||||
|
||||
it("installed bundle with valid models returns installed with version", () => {
|
||||
|
||||
Reference in New Issue
Block a user