Merge branch 'worktree-test+suite-overhaul-and-real-fixtures' into chore/consolidate-v2.0.0

# Conflicts:
#	tests/integration/generated/settings-matrix.test.ts
#	tests/integration/platform/api.test.ts
#	tests/integration/platform/concurrent.test.ts
#	tests/integration/platform/factory-multi-input.test.ts
#	tests/integration/security/adversarial-comprehensive.test.ts
#	tests/integration/security/adversarial-coverage-gaps.test.ts
#	tests/integration/security/adversarial-extended.test.ts
#	tests/integration/security/adversarial-final-gaps.test.ts
#	tests/integration/security/adversarial-matrix.test.ts
#	tests/integration/security/adversarial-security.test.ts
#	tests/integration/security/adversarial.test.ts
#	tests/integration/tools/image/color-adjustments.test.ts
This commit is contained in:
SnapOtter
2026-06-21 02:18:53 +08:00
547 changed files with 37191 additions and 4059 deletions
@@ -0,0 +1,95 @@
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { canvasFor } from "../../../../apps/api/src/routes/tools/aspect-pad.js";
import { fixtures, readFixture } from "../../../fixtures/index.js";
import {
buildTestApp,
createMultipartPayload,
loginAsAdmin,
type TestApp,
} from "../../test-server.js";
const MP4 = readFixture(fixtures.video.tiny("mp4"));
let testApp: TestApp;
let adminToken: string;
let sourceW: number;
let sourceH: number;
beforeAll(async () => {
testApp = await buildTestApp();
adminToken = await loginAsAdmin(testApp.app);
const tmpDir = mkdtempSync(join(tmpdir(), "apad-src-"));
const srcFile = join(tmpDir, "tiny.mp4");
writeFileSync(srcFile, MP4);
const info = await probeMedia(srcFile);
const v = info.streams.find((s) => s.type === "video");
sourceW = v?.width ?? 0;
sourceH = v?.height ?? 0;
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
async function runTool(settings: Record<string, unknown>) {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 },
{ name: "settings", content: JSON.stringify(settings) },
]);
return testApp.app.inject({
method: "POST",
url: "/api/v1/tools/video/aspect-pad",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
}
describe.skipIf(!ffmpegAvailable())("aspect-pad (requires ffmpeg)", () => {
it("pads to 1:1 producing a square output", async () => {
const res = await runTool({ target: "1:1" });
expect(res.statusCode).toBe(200);
const envelope = JSON.parse(res.body);
expect(envelope.downloadUrl).toBeDefined();
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
expect(dl.statusCode).toBe(200);
expect(dl.rawPayload.length).toBeGreaterThan(100);
const tmpDir = mkdtempSync(join(tmpdir(), "apad-test-"));
const probeFile = join(tmpDir, "padded.mp4");
writeFileSync(probeFile, dl.rawPayload);
const info = await probeMedia(probeFile);
const v = info.streams.find((s) => s.type === "video");
expect(v?.width).toBe(v?.height);
}, 60_000);
it("pads to 9:16 with dims matching canvasFor output", async () => {
const res = await runTool({ target: "9:16" });
expect(res.statusCode).toBe(200);
const envelope = JSON.parse(res.body);
expect(envelope.downloadUrl).toBeDefined();
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
expect(dl.statusCode).toBe(200);
const tmpDir = mkdtempSync(join(tmpdir(), "apad-test2-"));
const probeFile = join(tmpDir, "padded916.mp4");
writeFileSync(probeFile, dl.rawPayload);
const info = await probeMedia(probeFile);
const v = info.streams.find((s) => s.type === "video");
const expected = canvasFor(sourceW, sourceH, 9, 16);
expect(v?.width).toBe(expected.cw);
expect(v?.height).toBe(expected.ch);
}, 60_000);
it("rejects invalid color with 400", async () => {
const res = await runTool({ color: "red" });
expect(res.statusCode).toBe(400);
});
});
@@ -0,0 +1,188 @@
/**
* Integration tests for the auto-subtitles tool (/api/v1/tools/video/auto-subtitles).
*
* The transcription bundle (faster-whisper) is not installed locally, so the
* 501 gate is always hit. Validation paths (bad settings) fire after the 501
* check. The bundle-gated happy path lives in a skipped describe for
* in-container-after-install runs.
*/
import { readFileSync } from "node:fs";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { fixtures } from "../../../fixtures/index.js";
import {
buildTestApp,
createMultipartPayload,
loginAsAdmin,
type TestApp,
} from "../../test-server.js";
const MP4 = readFileSync(fixtures.video.tiny("mp4"));
let testApp: TestApp;
let app: TestApp["app"];
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
app = testApp.app;
adminToken = await loginAsAdmin(app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
describe("auto-subtitles", () => {
// -- 501 gate (always fires locally: bundle never installed) --
it("returns 501 FEATURE_NOT_INSTALLED when bundle is absent", async () => {
const { body, contentType } = createMultipartPayload([
{
name: "file",
filename: "tiny.mp4",
contentType: "video/mp4",
content: MP4,
},
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/video/auto-subtitles",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect(res.statusCode).toBe(501);
const json = JSON.parse(res.body);
expect(json.code).toBe("FEATURE_NOT_INSTALLED");
expect(json.feature).toBe("transcription");
expect(json.featureName).toBe("Transcription");
expect(json.estimatedSize).toBeDefined();
});
// -- Validation (501 fires before settings parse, so these also 501) --
it("returns 501 even with invalid format (gate fires first)", async () => {
const { body, contentType } = createMultipartPayload([
{
name: "file",
filename: "tiny.mp4",
contentType: "video/mp4",
content: MP4,
},
{
name: "settings",
content: JSON.stringify({ format: "ass" }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/video/auto-subtitles",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
// 501 because the bundle gate fires before settings validation
expect(res.statusCode).toBe(501);
const json = JSON.parse(res.body);
expect(json.code).toBe("FEATURE_NOT_INSTALLED");
});
it("rejects unauthenticated requests (401)", async () => {
const { body, contentType } = createMultipartPayload([
{
name: "file",
filename: "tiny.mp4",
contentType: "video/mp4",
content: MP4,
},
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/video/auto-subtitles",
headers: { "content-type": contentType },
body,
});
expect(res.statusCode).toBe(401);
});
// -- Bundle-gated happy path (skipped locally, runs after bundle install) --
// The transcription bundle is ~600 MB and only available in Docker.
// These tests run when the bundle is installed (e.g., during Task 7 smoke).
// Locally they always skip.
describe.skip("with transcription bundle installed", () => {
it("generates subtitles from video (202 + async)", async () => {
const { body, contentType } = createMultipartPayload([
{
name: "file",
filename: "tiny.mp4",
contentType: "video/mp4",
content: MP4,
},
{
name: "settings",
content: JSON.stringify({ format: "srt" }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/video/auto-subtitles",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect(res.statusCode).toBe(202);
const json = JSON.parse(res.body);
expect(json.jobId).toBeDefined();
expect(json.async).toBe(true);
}, 120_000);
it("generates VTT subtitles from video", async () => {
const { body, contentType } = createMultipartPayload([
{
name: "file",
filename: "tiny.mp4",
contentType: "video/mp4",
content: MP4,
},
{
name: "settings",
content: JSON.stringify({ format: "vtt" }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/video/auto-subtitles",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect(res.statusCode).toBe(202);
const json = JSON.parse(res.body);
expect(json.jobId).toBeDefined();
// Full VTT structure validation (WEBVTT header, dot timestamps)
// happens after polling completes in the Task 7 smoke.
}, 120_000);
});
});
@@ -0,0 +1,90 @@
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { canvasFor } from "../../../../apps/api/src/routes/tools/blur-pad.js";
import { fixtures, readFixture } from "../../../fixtures/index.js";
import {
buildTestApp,
createMultipartPayload,
loginAsAdmin,
type TestApp,
} from "../../test-server.js";
const MP4 = readFixture(fixtures.video.tiny("mp4"));
let testApp: TestApp;
let adminToken: string;
let sourceW: number;
let sourceH: number;
beforeAll(async () => {
testApp = await buildTestApp();
adminToken = await loginAsAdmin(testApp.app);
const tmpDir = mkdtempSync(join(tmpdir(), "bpad-src-"));
const srcFile = join(tmpDir, "tiny.mp4");
writeFileSync(srcFile, MP4);
const info = await probeMedia(srcFile);
const v = info.streams.find((s) => s.type === "video");
sourceW = v?.width ?? 0;
sourceH = v?.height ?? 0;
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
async function runTool(settings: Record<string, unknown>) {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 },
{ name: "settings", content: JSON.stringify(settings) },
]);
return testApp.app.inject({
method: "POST",
url: "/api/v1/tools/video/blur-pad",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
}
describe.skipIf(!ffmpegAvailable())("blur-pad (requires ffmpeg)", () => {
it("pads to 1:1 producing a square output", async () => {
const res = await runTool({ target: "1:1" });
expect(res.statusCode).toBe(200);
const envelope = JSON.parse(res.body);
expect(envelope.downloadUrl).toBeDefined();
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
expect(dl.statusCode).toBe(200);
expect(dl.rawPayload.length).toBeGreaterThan(100);
const tmpDir = mkdtempSync(join(tmpdir(), "bpad-test-"));
const probeFile = join(tmpDir, "blurpad.mp4");
writeFileSync(probeFile, dl.rawPayload);
const info = await probeMedia(probeFile);
const v = info.streams.find((s) => s.type === "video");
expect(v?.width).toBe(v?.height);
}, 60_000);
it("pads to default 16:9 with dims matching canvasFor output", async () => {
const res = await runTool({});
expect(res.statusCode).toBe(200);
const envelope = JSON.parse(res.body);
expect(envelope.downloadUrl).toBeDefined();
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
expect(dl.statusCode).toBe(200);
const tmpDir = mkdtempSync(join(tmpdir(), "bpad-test2-"));
const probeFile = join(tmpDir, "blurpad169.mp4");
writeFileSync(probeFile, dl.rawPayload);
const info = await probeMedia(probeFile);
const v = info.streams.find((s) => s.type === "video");
const expected = canvasFor(sourceW, sourceH, 16, 9);
expect(v?.width).toBe(expected.cw);
expect(v?.height).toBe(expected.ch);
}, 60_000);
});
@@ -0,0 +1,135 @@
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { fixtures, readFixture } from "../../../fixtures/index.js";
import {
buildTestApp,
createMultipartPayload,
loginAsAdmin,
type TestApp,
} from "../../test-server.js";
const MP4 = readFixture(fixtures.video.tiny("mp4"));
const SRT = readFixture(fixtures.video.subs.srt);
const VTT = readFixture(fixtures.video.subs.vtt);
const PNG = readFixture(fixtures.image.base.png200);
let testApp: TestApp;
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
adminToken = await loginAsAdmin(testApp.app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
/**
* Handles both the 200 sync-window path and the 202+poll path.
* burn-subtitles has executionHint "long", so 202 is expected in most runs.
*/
async function resolveResult(res: Awaited<ReturnType<typeof testApp.app.inject>>): Promise<{
path: "sync" | "poll";
downloadPayload: Buffer;
}> {
if (res.statusCode === 200) {
const envelope = JSON.parse(res.body);
expect(envelope.downloadUrl).toBeDefined();
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
expect(dl.statusCode).toBe(200);
return { path: "sync", downloadPayload: dl.rawPayload };
}
expect(res.statusCode).toBe(202);
const { jobId } = JSON.parse(res.body);
expect(jobId).toBeDefined();
const { db, schema } = await import("../../../../apps/api/src/db/index.js");
const { eq } = await import("drizzle-orm");
let row: { status: string; outputRefs: unknown } | undefined;
for (let i = 0; i < 120; i++) {
[row] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId));
if (row && ["completed", "failed", "canceled"].includes(row.status)) break;
await new Promise((r) => setTimeout(r, 500));
}
expect(row?.status).toBe("completed");
const outName = (row?.outputRefs as string[])[0].split("/").pop() as string;
const dl = await testApp.app.inject({
method: "GET",
url: `/api/v1/download/${jobId}/${encodeURIComponent(outName)}`,
});
expect(dl.statusCode).toBe(200);
return { path: "poll", downloadPayload: dl.rawPayload };
}
describe.skipIf(!ffmpegAvailable())("burn-subtitles (requires ffmpeg)", () => {
it("burns SRT subtitles onto a video", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 },
{ name: "file", filename: "tiny.srt", contentType: "application/x-subrip", content: SRT },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/tools/video/burn-subtitles",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
const { downloadPayload } = await resolveResult(res);
expect(downloadPayload.length).toBeGreaterThan(100);
const tmpDir = mkdtempSync(join(tmpdir(), "burn-srt-"));
const probeFile = join(tmpDir, "burned.mp4");
writeFileSync(probeFile, downloadPayload);
const info = await probeMedia(probeFile);
expect(info.streams.some((s) => s.type === "video")).toBe(true);
}, 120_000);
it("burns VTT subtitles onto a video", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 },
{ name: "file", filename: "tiny.vtt", contentType: "text/vtt", content: VTT },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/tools/video/burn-subtitles",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
const { downloadPayload } = await resolveResult(res);
expect(downloadPayload.length).toBeGreaterThan(100);
const tmpDir = mkdtempSync(join(tmpdir(), "burn-vtt-"));
const probeFile = join(tmpDir, "burned.mp4");
writeFileSync(probeFile, downloadPayload);
const info = await probeMedia(probeFile);
expect(info.streams.some((s) => s.type === "video")).toBe(true);
}, 120_000);
it("rejects a PNG as the second file (subtitle kind rejection)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 },
{ name: "file", filename: "image.png", contentType: "image/png", content: PNG },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/tools/video/burn-subtitles",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
// The inputKinds seam validates input 1 as kind "subtitle".
// A PNG fails the subtitle kind validation.
expect(res.statusCode).toBe(400);
const parsed = JSON.parse(res.body);
expect(parsed.error).toMatch(/subtitle/i);
}, 60_000);
});
@@ -0,0 +1,110 @@
import { spawnSync } from "node:child_process";
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ffmpegAvailable } from "@snapotter/media-engine";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { fixtures, readFixture } from "../../../fixtures/index.js";
import {
buildTestApp,
createMultipartPayload,
loginAsAdmin,
type TestApp,
} from "../../test-server.js";
const MP4 = readFixture(fixtures.video.tiny("mp4"));
const WEBM = readFixture(fixtures.video.tiny("webm"));
let testApp: TestApp;
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
adminToken = await loginAsAdmin(testApp.app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
async function runTool(
settings: Record<string, unknown>,
file: { filename: string; contentType: string; content: Buffer } = {
filename: "tiny.mp4",
contentType: "video/mp4",
content: MP4,
},
) {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: file.filename, contentType: file.contentType, content: file.content },
{ name: "settings", content: JSON.stringify(settings) },
]);
return testApp.app.inject({
method: "POST",
url: "/api/v1/tools/video/change-fps",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
}
describe.skipIf(!ffmpegAvailable())("change-fps (requires ffmpeg)", () => {
it("changes to 10 fps and verifies via ffprobe", async () => {
const res = await runTool({ fps: 10 });
expect(res.statusCode).toBe(200);
const envelope = JSON.parse(res.body);
expect(envelope.downloadUrl).toBeDefined();
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
expect(dl.statusCode).toBe(200);
expect(dl.rawPayload.length).toBeGreaterThan(100);
// Write to tmp and probe frame rate directly via ffprobe spawnSync
const tmpDir = mkdtempSync(join(tmpdir(), "fps-test-"));
const probeFile = join(tmpDir, "fps.mp4");
writeFileSync(probeFile, dl.rawPayload);
const result = spawnSync("ffprobe", [
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=r_frame_rate",
"-of",
"csv=p=0",
probeFile,
]);
const fps = result.stdout.toString().trim();
expect(fps).toBe("10/1");
}, 60_000);
it("changes fps on a webm input and keeps a webm-legal codec (regression: h264-in-webm exit 234)", async () => {
const res = await runTool(
{ fps: 1 },
{ filename: "tiny.webm", contentType: "video/webm", content: WEBM },
);
expect(res.statusCode).toBe(200);
const envelope = JSON.parse(res.body);
expect(envelope.downloadUrl).toBeDefined();
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
expect(dl.statusCode).toBe(200);
expect(dl.rawPayload.length).toBeGreaterThan(100);
// Output must be a valid webm with a webm-legal video codec (vp9/vp8/av1),
// never h264 -- which cannot be muxed into a webm container.
const tmpDir = mkdtempSync(join(tmpdir(), "fps-webm-test-"));
const probeFile = join(tmpDir, "out.webm");
writeFileSync(probeFile, dl.rawPayload);
const result = spawnSync("ffprobe", [
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=codec_name",
"-of",
"csv=p=0",
probeFile,
]);
const codec = result.stdout.toString().trim();
expect(["vp9", "vp8", "av1"]).toContain(codec);
}, 60_000);
});
@@ -0,0 +1,74 @@
import { ffmpegAvailable } from "@snapotter/media-engine";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { fixtures, readFixture } from "../../../fixtures/index.js";
import {
buildTestApp,
createMultipartPayload,
loginAsAdmin,
type TestApp,
} from "../../test-server.js";
const MP4 = readFixture(fixtures.video.tiny("mp4"));
let testApp: TestApp;
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
adminToken = await loginAsAdmin(testApp.app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
async function pollJob(jobId: string) {
const { db, schema } = await import("../../../../apps/api/src/db/index.js");
const { eq } = await import("drizzle-orm");
let row: { status: string; outputRefs: unknown } | undefined;
for (let i = 0; i < 120; i++) {
[row] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId));
if (row && ["completed", "failed", "canceled"].includes(row.status)) break;
await new Promise((r) => setTimeout(r, 500));
}
return row;
}
async function runTool(settings: Record<string, unknown>) {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 },
{ name: "settings", content: JSON.stringify(settings) },
]);
return testApp.app.inject({
method: "POST",
url: "/api/v1/tools/video/compress-video",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
}
describe.skipIf(!ffmpegAvailable())("compress-video (requires ffmpeg)", () => {
it("returns 202 and produces a compressed mp4", async () => {
const res = await runTool({ quality: "balanced" });
expect(res.statusCode).toBe(202);
const { jobId } = JSON.parse(res.body);
const row = await pollJob(jobId);
expect(row?.status).toBe("completed");
const outName = (row?.outputRefs as string[])[0].split("/").pop() as string;
expect(outName).toContain("_compressed.mp4");
const dl = await testApp.app.inject({
method: "GET",
url: `/api/v1/download/${jobId}/${encodeURIComponent(outName)}`,
});
expect(dl.statusCode).toBe(200);
expect(dl.rawPayload.length).toBeGreaterThan(100);
}, 90_000);
it("compresses with 480p resolution", async () => {
const res = await runTool({ quality: "strong", resolution: "480p" });
expect(res.statusCode).toBe(202);
const { jobId } = JSON.parse(res.body);
const row = await pollJob(jobId);
expect(row?.status).toBe("completed");
}, 90_000);
});
@@ -0,0 +1,95 @@
import { ffmpegAvailable } from "@snapotter/media-engine";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { fixtures, readFixture } from "../../../fixtures/index.js";
import {
buildTestApp,
createMultipartPayload,
loginAsAdmin,
type TestApp,
} from "../../test-server.js";
const MP4 = readFixture(fixtures.video.tiny("mp4"));
let testApp: TestApp;
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
adminToken = await loginAsAdmin(testApp.app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
async function runTool(settings: Record<string, unknown>) {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 },
{ name: "settings", content: JSON.stringify(settings) },
]);
return testApp.app.inject({
method: "POST",
url: "/api/v1/tools/video/convert-video",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
}
describe.skipIf(!ffmpegAvailable())("convert-video (requires ffmpeg)", () => {
it("returns 202 (long hint) and the job completes with a webm", async () => {
const res = await runTool({ format: "webm", quality: "small" });
expect(res.statusCode).toBe(202);
const { jobId } = JSON.parse(res.body);
// Poll the durable row until terminal (the long hint skips the sync window).
const { db, schema } = await import("../../../../apps/api/src/db/index.js");
const { eq } = await import("drizzle-orm");
let row: { status: string; outputRefs: unknown } | undefined;
for (let i = 0; i < 120; i++) {
[row] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId));
if (row && ["completed", "failed", "canceled"].includes(row.status)) break;
await new Promise((r) => setTimeout(r, 500));
}
expect(row?.status).toBe("completed");
const outName = (row?.outputRefs as string[])[0].split("/").pop() as string;
const dl = await testApp.app.inject({
method: "GET",
url: `/api/v1/download/${jobId}/${encodeURIComponent(outName)}`,
});
expect(dl.statusCode).toBe(200);
expect(outName.endsWith(".webm")).toBe(true);
expect(dl.rawPayload.length).toBeGreaterThan(100);
}, 90_000);
it("converts to mp4 (default settings)", async () => {
const res = await runTool({ format: "mp4" });
expect(res.statusCode).toBe(202);
const { jobId } = JSON.parse(res.body);
const { db, schema } = await import("../../../../apps/api/src/db/index.js");
const { eq } = await import("drizzle-orm");
let row: { status: string; outputRefs: unknown } | undefined;
for (let i = 0; i < 120; i++) {
[row] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId));
if (row && ["completed", "failed", "canceled"].includes(row.status)) break;
await new Promise((r) => setTimeout(r, 500));
}
expect(row?.status).toBe("completed");
const outName = (row?.outputRefs as string[])[0].split("/").pop() as string;
expect(outName.endsWith(".mp4")).toBe(true);
}, 90_000);
it("rejects a non-video upload", async () => {
const png = readFixture(fixtures.image.edge.px1);
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "x.mp4", contentType: "video/mp4", content: png },
{ name: "settings", content: JSON.stringify({ format: "mp4" }) },
]);
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/tools/video/convert-video",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBe(400);
expect(JSON.parse(res.body).error).toMatch(/still image|Unrecognized video/);
});
});
@@ -0,0 +1,71 @@
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { fixtures, readFixture } from "../../../fixtures/index.js";
import {
buildTestApp,
createMultipartPayload,
loginAsAdmin,
type TestApp,
} from "../../test-server.js";
const MP4 = readFixture(fixtures.video.tiny("mp4"));
let testApp: TestApp;
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
adminToken = await loginAsAdmin(testApp.app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
async function runTool(settings: Record<string, unknown>) {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 },
{ name: "settings", content: JSON.stringify(settings) },
]);
return testApp.app.inject({
method: "POST",
url: "/api/v1/tools/video/crop-video",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
}
describe.skipIf(!ffmpegAvailable())("crop-video (requires ffmpeg)", () => {
it("crops to 32x32 and returns 200", async () => {
const res = await runTool({ width: 32, height: 32, x: 0, y: 0 });
expect(res.statusCode).toBe(200);
const envelope = JSON.parse(res.body);
expect(envelope.downloadUrl).toBeDefined();
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
expect(dl.statusCode).toBe(200);
expect(dl.rawPayload.length).toBeGreaterThan(100);
const tmpDir = mkdtempSync(join(tmpdir(), "crop-test-"));
const probeFile = join(tmpDir, "cropped.mp4");
writeFileSync(probeFile, dl.rawPayload);
const info = await probeMedia(probeFile);
const v = info.streams.find((s) => s.type === "video");
expect(v?.width).toBe(32);
expect(v?.height).toBe(32);
}, 60_000);
it("rejects crop rect exceeding video dimensions with 422", async () => {
const res = await runTool({ width: 9999, height: 9999 });
expect(res.statusCode).toBe(422);
const body = JSON.parse(res.body);
expect(body.details || body.error).toMatch(/exceeds video size/i);
}, 60_000);
it("rejects missing width with 400", async () => {
const res = await runTool({});
expect(res.statusCode).toBe(400);
});
});
@@ -0,0 +1,74 @@
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { fixtures, readFixture } from "../../../fixtures/index.js";
import {
buildTestApp,
createMultipartPayload,
loginAsAdmin,
type TestApp,
} from "../../test-server.js";
const MP4 = readFixture(fixtures.video.tiny("mp4"));
const SRT = readFixture(fixtures.video.subs.srt);
let testApp: TestApp;
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
adminToken = await loginAsAdmin(testApp.app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
describe.skipIf(!ffmpegAvailable())("embed-subtitles (requires ffmpeg)", () => {
it("embeds an SRT subtitle track into an MP4", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 },
{ name: "file", filename: "tiny.srt", contentType: "application/x-subrip", content: SRT },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/tools/video/embed-subtitles",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBe(200);
const envelope = JSON.parse(res.body);
expect(envelope.downloadUrl).toBeDefined();
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
expect(dl.statusCode).toBe(200);
expect(dl.rawPayload.length).toBeGreaterThan(100);
// Probe the output: should have a non-av stream (subtitle mapped as "other")
const tmpDir = mkdtempSync(join(tmpdir(), "embed-sub-"));
const probeFile = join(tmpDir, "embedded.mp4");
writeFileSync(probeFile, dl.rawPayload);
const info = await probeMedia(probeFile);
expect(info.streams.some((s) => s.type === "other")).toBe(true);
}, 60_000);
it("rejects invalid language code 'english' with 400", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 },
{ name: "file", filename: "tiny.srt", contentType: "application/x-subrip", content: SRT },
{ name: "settings", content: JSON.stringify({ language: "english" }) },
]);
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/tools/video/embed-subtitles",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBe(400);
}, 60_000);
});
@@ -0,0 +1,68 @@
import { ffmpegAvailable } from "@snapotter/media-engine";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { fixtures, readFixture } from "../../../fixtures/index.js";
import {
buildTestApp,
createMultipartPayload,
loginAsAdmin,
type TestApp,
} from "../../test-server.js";
const MP4 = readFixture(fixtures.video.tiny("mp4"));
let testApp: TestApp;
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
adminToken = await loginAsAdmin(testApp.app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
async function runTool(settings: Record<string, unknown>) {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 },
{ name: "settings", content: JSON.stringify(settings) },
]);
return testApp.app.inject({
method: "POST",
url: "/api/v1/tools/video/extract-audio",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
}
describe.skipIf(!ffmpegAvailable())("extract-audio (requires ffmpeg)", () => {
it("extracts audio from mp4 as mp3 and returns 200", async () => {
const res = await runTool({ format: "mp3" });
expect(res.statusCode).toBe(200);
const envelope = JSON.parse(res.body);
expect(envelope.downloadUrl).toBeDefined();
const dl = await testApp.app.inject({
method: "GET",
url: envelope.downloadUrl,
});
expect(dl.statusCode).toBe(200);
expect(dl.rawPayload.length).toBeGreaterThan(100);
const outName = envelope.downloadUrl.split("/").pop() as string;
expect(outName.endsWith(".mp3")).toBe(true);
}, 60_000);
it("extracts audio from mp4 as wav and returns 200", async () => {
const res = await runTool({ format: "wav" });
expect(res.statusCode).toBe(200);
const envelope = JSON.parse(res.body);
expect(envelope.downloadUrl).toBeDefined();
const dl = await testApp.app.inject({
method: "GET",
url: envelope.downloadUrl,
});
expect(dl.statusCode).toBe(200);
expect(dl.rawPayload.length).toBeGreaterThan(100);
const outName = envelope.downloadUrl.split("/").pop() as string;
expect(outName.endsWith(".wav")).toBe(true);
}, 60_000);
});
@@ -0,0 +1,66 @@
import { ffmpegAvailable } from "@snapotter/media-engine";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { fixtures, readFixture } from "../../../fixtures/index.js";
import {
buildTestApp,
createMultipartPayload,
loginAsAdmin,
type TestApp,
} from "../../test-server.js";
const MKV = readFixture(fixtures.video.subs.mkv);
const MP4 = readFixture(fixtures.video.tiny("mp4"));
let testApp: TestApp;
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
adminToken = await loginAsAdmin(testApp.app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
describe.skipIf(!ffmpegAvailable())("extract-subtitles (requires ffmpeg)", () => {
it("extracts the subtitle track from an MKV with embedded subs", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "tiny-subs.mkv", contentType: "video/x-matroska", content: MKV },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/tools/video/extract-subtitles",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBe(200);
const envelope = JSON.parse(res.body);
expect(envelope.downloadUrl).toBeDefined();
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
expect(dl.statusCode).toBe(200);
const srtContent = dl.rawPayload.toString("utf8");
expect(srtContent).toContain("SnapOtter subtitle one");
}, 60_000);
it("rejects a video with no subtitle track (422)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/tools/video/extract-subtitles",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBe(422);
const parsed = JSON.parse(res.body);
expect(parsed.details).toMatch(/no subtitle track/i);
}, 60_000);
});
@@ -0,0 +1,82 @@
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { fixtures, readFixture } from "../../../fixtures/index.js";
import {
buildTestApp,
createMultipartPayload,
loginAsAdmin,
type TestApp,
} from "../../test-server.js";
const GIF = readFixture(fixtures.image.animated.gif);
let testApp: TestApp;
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
adminToken = await loginAsAdmin(testApp.app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
async function runTool(settings: Record<string, unknown>) {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "animated.gif", contentType: "image/gif", content: GIF },
{ name: "settings", content: JSON.stringify(settings) },
]);
return testApp.app.inject({
method: "POST",
url: "/api/v1/tools/video/gif-to-video",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
}
describe.skipIf(!ffmpegAvailable())("gif-to-video (requires ffmpeg)", () => {
it("converts a GIF to mp4 by default and returns 200", async () => {
const res = await runTool({});
expect(res.statusCode).toBe(200);
const envelope = JSON.parse(res.body);
expect(envelope.downloadUrl).toBeDefined();
const dl = await testApp.app.inject({
method: "GET",
url: envelope.downloadUrl,
});
expect(dl.statusCode).toBe(200);
expect(dl.rawPayload.length).toBeGreaterThan(100);
// Probe the downloaded file to verify it has a video stream
const tmpDir = mkdtempSync(join(tmpdir(), "gif2vid-"));
const probeFile = join(tmpDir, "out.mp4");
writeFileSync(probeFile, dl.rawPayload);
const info = await probeMedia(probeFile);
expect(info.streams.some((s) => s.type === "video")).toBe(true);
}, 60_000);
it("converts a GIF to webm when format is webm", async () => {
const res = await runTool({ format: "webm" });
expect(res.statusCode).toBe(200);
const envelope = JSON.parse(res.body);
expect(envelope.downloadUrl).toBeDefined();
const dl = await testApp.app.inject({
method: "GET",
url: envelope.downloadUrl,
});
expect(dl.statusCode).toBe(200);
expect(dl.rawPayload.length).toBeGreaterThan(100);
const tmpDir = mkdtempSync(join(tmpdir(), "gif2webm-"));
const probeFile = join(tmpDir, "out.webm");
writeFileSync(probeFile, dl.rawPayload);
const info = await probeMedia(probeFile);
expect(info.streams.some((s) => s.type === "video")).toBe(true);
}, 60_000);
});
@@ -0,0 +1,85 @@
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { fixtures, readFixture } from "../../../fixtures/index.js";
import {
buildTestApp,
createMultipartPayload,
loginAsAdmin,
type TestApp,
} from "../../test-server.js";
const JPG = readFixture(fixtures.image.base.jpg100);
const PNG = readFixture(fixtures.image.base.png200);
let testApp: TestApp;
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
adminToken = await loginAsAdmin(testApp.app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
describe.skipIf(!ffmpegAvailable())("images-to-video (requires ffmpeg)", () => {
it("creates a slideshow from two images with default settings", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "photo.jpg", contentType: "image/jpeg", content: JPG },
{ name: "file", filename: "photo.png", contentType: "image/png", content: PNG },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/tools/video/images-to-video",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBe(200);
const envelope = JSON.parse(res.body);
expect(envelope.downloadUrl).toBeDefined();
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
expect(dl.statusCode).toBe(200);
const tmpDir = mkdtempSync(join(tmpdir(), "slideshow-"));
const probeFile = join(tmpDir, "slideshow.mp4");
writeFileSync(probeFile, dl.rawPayload);
const info = await probeMedia(probeFile);
// Duration should be roughly 4s (2 images x 2s default, 30% tolerance)
expect(info.durationS).not.toBeNull();
const dur = info.durationS as number;
expect(dur).toBeGreaterThan(4 * 0.7);
expect(dur).toBeLessThan(4 * 1.3);
// Resolution should be 1280x720 (720p default)
const v = info.streams.find((s) => s.type === "video");
expect(v).toBeDefined();
expect(v?.width).toBe(1280);
expect(v?.height).toBe(720);
}, 60_000);
it("rejects a single image with 422", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "photo.jpg", contentType: "image/jpeg", content: JPG },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/tools/video/images-to-video",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
// fast hint: inline 422
expect(res.statusCode).toBe(422);
const parsed = JSON.parse(res.body);
expect(parsed.details).toMatch(/at least two images/i);
}, 60_000);
});
@@ -0,0 +1,143 @@
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { fixtures, readFixture } from "../../../fixtures/index.js";
import {
buildTestApp,
createMultipartPayload,
loginAsAdmin,
type TestApp,
} from "../../test-server.js";
const MP4 = readFixture(fixtures.video.tiny("mp4"));
let testApp: TestApp;
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
adminToken = await loginAsAdmin(testApp.app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
/**
* Handles both the 200 sync-window path and the 202+poll path.
* merge-videos has executionHint "long", so expect 202 in most runs.
*/
async function resolveResult(res: Awaited<ReturnType<typeof testApp.app.inject>>): Promise<{
path: "sync" | "poll";
downloadPayload: Buffer;
}> {
if (res.statusCode === 200) {
const envelope = JSON.parse(res.body);
expect(envelope.downloadUrl).toBeDefined();
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
expect(dl.statusCode).toBe(200);
return { path: "sync", downloadPayload: dl.rawPayload };
}
expect(res.statusCode).toBe(202);
const { jobId } = JSON.parse(res.body);
expect(jobId).toBeDefined();
const { db, schema } = await import("../../../../apps/api/src/db/index.js");
const { eq } = await import("drizzle-orm");
let row: { status: string; outputRefs: unknown } | undefined;
for (let i = 0; i < 120; i++) {
[row] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId));
if (row && ["completed", "failed", "canceled"].includes(row.status)) break;
await new Promise((r) => setTimeout(r, 500));
}
expect(row?.status).toBe("completed");
const outName = (row?.outputRefs as string[])[0].split("/").pop() as string;
const dl = await testApp.app.inject({
method: "GET",
url: `/api/v1/download/${jobId}/${encodeURIComponent(outName)}`,
});
expect(dl.statusCode).toBe(200);
return { path: "poll", downloadPayload: dl.rawPayload };
}
describe.skipIf(!ffmpegAvailable())("merge-videos (requires ffmpeg)", () => {
it("merges two copies of tiny.mp4 into a single video", async () => {
// Send TWO file parts named "file" (the multi-input path)
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "a.mp4", contentType: "video/mp4", content: MP4 },
{ name: "file", filename: "b.mp4", contentType: "video/mp4", content: MP4 },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/tools/video/merge-videos",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
const { path, downloadPayload } = await resolveResult(res);
expect(downloadPayload.length).toBeGreaterThan(100);
// Probe the merged output: duration should be roughly 2x the source
const tmpDir = mkdtempSync(join(tmpdir(), "merge-test-"));
const probeFile = join(tmpDir, "merged.mp4");
writeFileSync(probeFile, downloadPayload);
const info = await probeMedia(probeFile);
// Probe the source for comparison
const srcFile = join(tmpDir, "src.mp4");
writeFileSync(srcFile, MP4);
const srcInfo = await probeMedia(srcFile);
const srcDuration = srcInfo.durationS ?? 0;
expect(info.durationS).not.toBeNull();
const merged = info.durationS as number;
// Within 30% tolerance of 2x source
expect(merged).toBeGreaterThan(srcDuration * 2 * 0.7);
expect(merged).toBeLessThan(srcDuration * 2 * 1.3);
// Record which path was taken for the report
// eslint-disable-next-line no-console
console.log(`merge-videos happy path: ${path}`);
}, 120_000);
it("rejects a single file", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "only.mp4", contentType: "video/mp4", content: MP4 },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/tools/video/merge-videos",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
// merge-videos has executionHint "long", so 202 is returned immediately.
// The worker then fails with InputValidationError. Poll for the failure.
expect(res.statusCode).toBe(202);
const { jobId } = JSON.parse(res.body);
expect(jobId).toBeDefined();
const { db, schema } = await import("../../../../apps/api/src/db/index.js");
const { eq } = await import("drizzle-orm");
let row: { status: string; error: unknown } | undefined;
for (let i = 0; i < 60; i++) {
[row] = await db
.select({ status: schema.jobs.status, error: schema.jobs.error })
.from(schema.jobs)
.where(eq(schema.jobs.id, jobId));
if (row && ["completed", "failed", "canceled"].includes(row.status)) break;
await new Promise((r) => setTimeout(r, 500));
}
// Observed behavior: 202 (async) -> job status "failed" with error message.
// The plan noted "400/422" but long-hint tools skip the sync window.
expect(row?.status).toBe("failed");
const error = row?.error as { message?: string } | null;
expect(error?.message).toMatch(/at least two/i);
}, 60_000);
});
@@ -0,0 +1,63 @@
import { ffmpegAvailable } from "@snapotter/media-engine";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { fixtures, readFixture } from "../../../fixtures/index.js";
import {
buildTestApp,
createMultipartPayload,
loginAsAdmin,
type TestApp,
} from "../../test-server.js";
const MP4 = readFixture(fixtures.video.tiny("mp4"));
let testApp: TestApp;
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
adminToken = await loginAsAdmin(testApp.app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
describe.skipIf(!ffmpegAvailable())("mute-video (requires ffmpeg)", () => {
it("removes audio and returns 200 with settings {}", async () => {
// Verify the factory default for empty settings schema: send NO settings part
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 },
]);
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/tools/video/mute-video",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBe(200);
const envelope = JSON.parse(res.body);
expect(envelope.downloadUrl).toBeDefined();
const dl = await testApp.app.inject({
method: "GET",
url: envelope.downloadUrl,
});
expect(dl.statusCode).toBe(200);
expect(dl.rawPayload.length).toBeGreaterThan(100);
}, 60_000);
it("also works with explicit empty settings", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/tools/video/mute-video",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBe(200);
const envelope = JSON.parse(res.body);
expect(envelope.downloadUrl).toBeDefined();
}, 60_000);
});
@@ -0,0 +1,103 @@
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { fixtures, readFixture } from "../../../fixtures/index.js";
import {
buildTestApp,
createMultipartPayload,
loginAsAdmin,
type TestApp,
} from "../../test-server.js";
const MP4 = readFixture(fixtures.video.tiny("mp4"));
const MP3 = readFixture(fixtures.audio.tiny("mp3"));
let testApp: TestApp;
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
adminToken = await loginAsAdmin(testApp.app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
describe.skipIf(!ffmpegAvailable())("replace-audio (requires ffmpeg)", () => {
it("replaces video audio with the supplied audio track", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 },
{ name: "file", filename: "tiny.mp3", contentType: "audio/mpeg", content: MP3 },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/tools/video/replace-audio",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
// replace-audio is fast-hint, so expect 200 (sync window)
expect(res.statusCode).toBe(200);
const envelope = JSON.parse(res.body);
expect(envelope.downloadUrl).toBeDefined();
const dl = await testApp.app.inject({
method: "GET",
url: envelope.downloadUrl,
});
expect(dl.statusCode).toBe(200);
expect(dl.rawPayload.length).toBeGreaterThan(100);
// Probe the output: must have both video and audio streams
const tmpDir = mkdtempSync(join(tmpdir(), "replace-audio-test-"));
const probeFile = join(tmpDir, "replaced.mp4");
writeFileSync(probeFile, dl.rawPayload);
const info = await probeMedia(probeFile);
expect(info.streams.some((s) => s.type === "video")).toBe(true);
expect(info.streams.some((s) => s.type === "audio")).toBe(true);
}, 60_000);
it("rejects when files are in wrong order (mp3 first, mp4 second)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "tiny.mp3", contentType: "audio/mpeg", content: MP3 },
{ name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/tools/video/replace-audio",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
// The inputKinds seam validates input 0 as kind "video".
// mp3 has no video stream, so the handler rejects before processV2.
// InputValidationError defaults to 400.
expect(res.statusCode).toBe(400);
const parsed = JSON.parse(res.body);
expect(parsed.error).toMatch(/no video stream/i);
}, 60_000);
it("rejects when only one file is provided", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/tools/video/replace-audio",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
// Fast-hint tool: processV2 checks input count and throws,
// caught by the factory's generic catch -> 422.
expect(res.statusCode).toBe(422);
const parsed = JSON.parse(res.body);
expect(parsed.details).toMatch(/video and an audio/i);
}, 60_000);
});
@@ -0,0 +1,63 @@
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { fixtures, readFixture } from "../../../fixtures/index.js";
import {
buildTestApp,
createMultipartPayload,
loginAsAdmin,
type TestApp,
} from "../../test-server.js";
const MP4 = readFixture(fixtures.video.tiny("mp4"));
let testApp: TestApp;
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
adminToken = await loginAsAdmin(testApp.app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
async function runTool(settings: Record<string, unknown>) {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 },
{ name: "settings", content: JSON.stringify(settings) },
]);
return testApp.app.inject({
method: "POST",
url: "/api/v1/tools/video/resize-video",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
}
describe.skipIf(!ffmpegAvailable())("resize-video (requires ffmpeg)", () => {
it("resizes to width 64 and returns 200", async () => {
const res = await runTool({ width: 64 });
expect(res.statusCode).toBe(200);
const envelope = JSON.parse(res.body);
expect(envelope.downloadUrl).toBeDefined();
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
expect(dl.statusCode).toBe(200);
expect(dl.rawPayload.length).toBeGreaterThan(100);
const tmpDir = mkdtempSync(join(tmpdir(), "resize-test-"));
const probeFile = join(tmpDir, "resized.mp4");
writeFileSync(probeFile, dl.rawPayload);
const info = await probeMedia(probeFile);
const v = info.streams.find((s) => s.type === "video");
expect(v?.width).toBe(64);
}, 60_000);
it("rejects custom preset with no dimensions", async () => {
const res = await runTool({ preset: "custom" });
expect(res.statusCode).toBe(400);
});
});
@@ -0,0 +1,69 @@
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { fixtures, readFixture } from "../../../fixtures/index.js";
import {
buildTestApp,
createMultipartPayload,
loginAsAdmin,
type TestApp,
} from "../../test-server.js";
const MP4 = readFixture(fixtures.video.tiny("mp4"));
let testApp: TestApp;
let adminToken: string;
let sourceDurationS: number;
beforeAll(async () => {
testApp = await buildTestApp();
adminToken = await loginAsAdmin(testApp.app);
const tmpDir = mkdtempSync(join(tmpdir(), "rev-src-"));
const srcFile = join(tmpDir, "tiny.mp4");
writeFileSync(srcFile, MP4);
const info = await probeMedia(srcFile);
sourceDurationS = info.durationS ?? 1;
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
async function runTool(settings: Record<string, unknown>) {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 },
{ name: "settings", content: JSON.stringify(settings) },
]);
return testApp.app.inject({
method: "POST",
url: "/api/v1/tools/video/reverse-video",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
}
describe.skipIf(!ffmpegAvailable())("reverse-video (requires ffmpeg)", () => {
it("reverses a clip and returns 200 with similar duration", async () => {
const res = await runTool({});
expect(res.statusCode).toBe(200);
const envelope = JSON.parse(res.body);
expect(envelope.downloadUrl).toBeDefined();
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
expect(dl.statusCode).toBe(200);
expect(dl.rawPayload.length).toBeGreaterThan(100);
const tmpDir = mkdtempSync(join(tmpdir(), "rev-test-"));
const probeFile = join(tmpDir, "reversed.mp4");
writeFileSync(probeFile, dl.rawPayload);
const info = await probeMedia(probeFile);
expect(info.durationS).not.toBeNull();
const tolerance = sourceDurationS * 0.3;
expect(info.durationS as number).toBeGreaterThan(sourceDurationS - tolerance);
expect(info.durationS as number).toBeLessThan(sourceDurationS + tolerance);
}, 60_000);
});
@@ -0,0 +1,68 @@
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { fixtures, readFixture } from "../../../fixtures/index.js";
import {
buildTestApp,
createMultipartPayload,
loginAsAdmin,
type TestApp,
} from "../../test-server.js";
const MP4 = readFixture(fixtures.video.tiny("mp4"));
let testApp: TestApp;
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
adminToken = await loginAsAdmin(testApp.app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
async function runTool(settings: Record<string, unknown>) {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 },
{ name: "settings", content: JSON.stringify(settings) },
]);
return testApp.app.inject({
method: "POST",
url: "/api/v1/tools/video/rotate-video",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
}
describe.skipIf(!ffmpegAvailable())("rotate-video (requires ffmpeg)", () => {
it("rotates cw90 and swaps dimensions", async () => {
// First probe the source to know its original dimensions
const srcDir = mkdtempSync(join(tmpdir(), "rotate-src-"));
const srcFile = join(srcDir, "tiny.mp4");
writeFileSync(srcFile, MP4);
const srcInfo = await probeMedia(srcFile);
const srcV = srcInfo.streams.find((s) => s.type === "video");
const srcW = srcV?.width ?? 0;
const srcH = srcV?.height ?? 0;
const res = await runTool({ transform: "cw90" });
expect(res.statusCode).toBe(200);
const envelope = JSON.parse(res.body);
expect(envelope.downloadUrl).toBeDefined();
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
expect(dl.statusCode).toBe(200);
const tmpDir = mkdtempSync(join(tmpdir(), "rotate-test-"));
const probeFile = join(tmpDir, "rotated.mp4");
writeFileSync(probeFile, dl.rawPayload);
const info = await probeMedia(probeFile);
const v = info.streams.find((s) => s.type === "video");
// After 90-degree rotation, width and height should swap
expect(v?.width).toBe(srcH);
expect(v?.height).toBe(srcW);
}, 60_000);
});
@@ -0,0 +1,97 @@
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { fixtures, readFixture } from "../../../fixtures/index.js";
import {
buildTestApp,
createMultipartPayload,
loginAsAdmin,
type TestApp,
} from "../../test-server.js";
const MP4 = readFixture(fixtures.video.tiny("mp4"));
let testApp: TestApp;
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
adminToken = await loginAsAdmin(testApp.app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
async function runTool(settings: Record<string, unknown> = {}) {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 },
{ name: "settings", content: JSON.stringify(settings) },
]);
return testApp.app.inject({
method: "POST",
url: "/api/v1/tools/video/stabilize-video",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
}
/**
* Handles both the 200 sync-window path and the 202+poll path.
* Long-hint tools may complete within the sync window (200) or go async (202).
*/
async function resolveResult(res: Awaited<ReturnType<typeof runTool>>): Promise<{
path: "sync" | "poll";
downloadPayload: Buffer;
}> {
if (res.statusCode === 200) {
const envelope = JSON.parse(res.body);
expect(envelope.downloadUrl).toBeDefined();
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
expect(dl.statusCode).toBe(200);
return { path: "sync", downloadPayload: dl.rawPayload };
}
expect(res.statusCode).toBe(202);
const { jobId } = JSON.parse(res.body);
expect(jobId).toBeDefined();
const { db, schema } = await import("../../../../apps/api/src/db/index.js");
const { eq } = await import("drizzle-orm");
let row: { status: string; outputRefs: unknown } | undefined;
for (let i = 0; i < 120; i++) {
[row] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId));
if (row && ["completed", "failed", "canceled"].includes(row.status)) break;
await new Promise((r) => setTimeout(r, 500));
}
expect(row?.status).toBe("completed");
const outName = (row?.outputRefs as string[])[0].split("/").pop() as string;
const dl = await testApp.app.inject({
method: "GET",
url: `/api/v1/download/${jobId}/${encodeURIComponent(outName)}`,
});
expect(dl.statusCode).toBe(200);
return { path: "poll", downloadPayload: dl.rawPayload };
}
describe.skipIf(!ffmpegAvailable())("stabilize-video (requires ffmpeg)", () => {
it("stabilizes a video and returns a downloadable mp4", async () => {
const res = await runTool({});
const { downloadPayload } = await resolveResult(res);
expect(downloadPayload.length).toBeGreaterThan(100);
const tmpDir = mkdtempSync(join(tmpdir(), "stab-test-"));
const probeFile = join(tmpDir, "stabilized.mp4");
writeFileSync(probeFile, downloadPayload);
const info = await probeMedia(probeFile);
const v = info.streams.find((s) => s.type === "video");
expect(v).toBeDefined();
}, 120_000);
it("rejects smoothing out of range with 400", async () => {
const res = await runTool({ smoothing: 100 });
expect(res.statusCode).toBe(400);
});
});
@@ -0,0 +1,83 @@
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { fixtures, readFixture } from "../../../fixtures/index.js";
import {
buildTestApp,
createMultipartPayload,
loginAsAdmin,
type TestApp,
} from "../../test-server.js";
const MP4 = readFixture(fixtures.video.tiny("mp4"));
let testApp: TestApp;
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
adminToken = await loginAsAdmin(testApp.app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
async function runTool(settings: Record<string, unknown>) {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 },
{ name: "settings", content: JSON.stringify(settings) },
]);
return testApp.app.inject({
method: "POST",
url: "/api/v1/tools/video/trim-video",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
}
describe.skipIf(!ffmpegAvailable())("trim-video (requires ffmpeg)", () => {
it("trims a clip (fast, stream-copy) and returns 200", async () => {
const res = await runTool({ startS: 0, endS: 0.5 });
expect(res.statusCode).toBe(200);
const envelope = JSON.parse(res.body);
expect(envelope.downloadUrl).toBeDefined();
const dl = await testApp.app.inject({
method: "GET",
url: envelope.downloadUrl,
});
expect(dl.statusCode).toBe(200);
expect(dl.rawPayload.length).toBeGreaterThan(100);
// Verify trimmed duration via probeMedia (the plan's verify-don't-trust point)
const tmpDir = mkdtempSync(join(tmpdir(), "trim-test-"));
const probeFile = join(tmpDir, "trimmed.mp4");
writeFileSync(probeFile, dl.rawPayload);
const info = await probeMedia(probeFile);
// The trimmed file should be approximately 0.5s (stream-copy may be
// slightly longer due to keyframe alignment, but well under 2s).
expect(info.durationS).not.toBeNull();
expect(info.durationS as number).toBeLessThanOrEqual(2);
expect(info.durationS as number).toBeGreaterThan(0);
}, 60_000);
it("trims with precise re-encode and returns 200", async () => {
const res = await runTool({ startS: 0, endS: 0.5, precise: true });
expect(res.statusCode).toBe(200);
const envelope = JSON.parse(res.body);
expect(envelope.downloadUrl).toBeDefined();
const dl = await testApp.app.inject({
method: "GET",
url: envelope.downloadUrl,
});
expect(dl.statusCode).toBe(200);
expect(dl.rawPayload.length).toBeGreaterThan(100);
}, 60_000);
it("rejects when end is before start", async () => {
const res = await runTool({ startS: 0.5, endS: 0.2 });
expect(res.statusCode).toBe(400);
});
});
@@ -0,0 +1,53 @@
import { ffmpegAvailable } from "@snapotter/media-engine";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { fixtures, readFixture } from "../../../fixtures/index.js";
import {
buildTestApp,
createMultipartPayload,
loginAsAdmin,
type TestApp,
} from "../../test-server.js";
const MP4 = readFixture(fixtures.video.tiny("mp4"));
let testApp: TestApp;
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
adminToken = await loginAsAdmin(testApp.app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
async function runTool(settings: Record<string, unknown>) {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 },
{ name: "settings", content: JSON.stringify(settings) },
]);
return testApp.app.inject({
method: "POST",
url: "/api/v1/tools/video/video-color",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
}
describe.skipIf(!ffmpegAvailable())("video-color (requires ffmpeg)", () => {
it("desaturates (saturation 0) and returns 200", async () => {
const res = await runTool({ saturation: 0 });
expect(res.statusCode).toBe(200);
const envelope = JSON.parse(res.body);
expect(envelope.downloadUrl).toBeDefined();
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
expect(dl.statusCode).toBe(200);
expect(dl.rawPayload.length).toBeGreaterThan(100);
}, 60_000);
it("rejects brightness out of range with 400", async () => {
const res = await runTool({ brightness: 5 });
expect(res.statusCode).toBe(400);
});
});
@@ -0,0 +1,79 @@
import { spawnSync } from "node:child_process";
import { mkdtempSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ffmpegAvailable } from "@snapotter/media-engine";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { fixtures, readFixture } from "../../../fixtures/index.js";
import {
buildTestApp,
createMultipartPayload,
loginAsAdmin,
type TestApp,
} from "../../test-server.js";
const MP4 = readFixture(fixtures.video.tiny("mp4"));
let testApp: TestApp;
let adminToken: string;
let silentMp4: Buffer;
beforeAll(async () => {
testApp = await buildTestApp();
adminToken = await loginAsAdmin(testApp.app);
// Generate a silent (no audio track) clip for the rejection test
const tmpDir = mkdtempSync(join(tmpdir(), "loudnorm-fixture-"));
const silentPath = join(tmpDir, "silent.mp4");
const result = spawnSync(
"ffmpeg",
["-f", "lavfi", "-i", "color=red:s=64x64:d=1", "-an", "-y", silentPath],
{ timeout: 15_000 },
);
if (result.status !== 0) {
throw new Error(`Failed to generate silent fixture: ${result.stderr?.toString()}`);
}
silentMp4 = readFileSync(silentPath);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
describe.skipIf(!ffmpegAvailable())("video-loudnorm (requires ffmpeg)", () => {
it("normalizes audio and returns 200", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/tools/video/video-loudnorm",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBe(200);
const envelope = JSON.parse(res.body);
expect(envelope.downloadUrl).toBeDefined();
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
expect(dl.statusCode).toBe(200);
expect(dl.rawPayload.length).toBeGreaterThan(100);
}, 60_000);
it("rejects a video with no audio track with 422", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "silent.mp4", contentType: "video/mp4", content: silentMp4 },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/tools/video/video-loudnorm",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBe(422);
const resBody = JSON.parse(res.body);
expect(resBody.details || resBody.error).toMatch(/no audio track/i);
}, 60_000);
});
@@ -0,0 +1,60 @@
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { fixtures, readFixture } from "../../../fixtures/index.js";
import {
buildTestApp,
createMultipartPayload,
loginAsAdmin,
type TestApp,
} from "../../test-server.js";
const MP4 = readFixture(fixtures.video.tiny("mp4"));
let testApp: TestApp;
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
adminToken = await loginAsAdmin(testApp.app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
describe.skipIf(!ffmpegAvailable())("video-metadata (requires ffmpeg)", () => {
it("strips metadata and returns probe data in the envelope", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/tools/video/video-metadata",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBe(200);
const envelope = JSON.parse(res.body);
expect(envelope.downloadUrl).toBeDefined();
// Sync resultPayload passthrough: metadata at top level of envelope
expect(envelope.metadata).toBeDefined();
expect(Array.isArray(envelope.metadata.streams)).toBe(true);
expect(envelope.metadata.streams.length).toBeGreaterThan(0);
// Download and probe the cleaned file
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
expect(dl.statusCode).toBe(200);
const tmpDir = mkdtempSync(join(tmpdir(), "vid-meta-"));
const probeFile = join(tmpDir, "clean.mp4");
writeFileSync(probeFile, dl.rawPayload);
const info = await probeMedia(probeFile);
expect(info.streams.some((s) => s.type === "video")).toBe(true);
}, 60_000);
});
@@ -0,0 +1,103 @@
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { fixtures, readFixture } from "../../../fixtures/index.js";
import {
buildTestApp,
createMultipartPayload,
loginAsAdmin,
type TestApp,
} from "../../test-server.js";
const MP4 = readFixture(fixtures.video.tiny("mp4"));
let testApp: TestApp;
let adminToken: string;
let sourceDurationS: number;
beforeAll(async () => {
testApp = await buildTestApp();
adminToken = await loginAsAdmin(testApp.app);
// Probe source duration and verify sampleRate for comparison
const tmpDir = mkdtempSync(join(tmpdir(), "speed-src-"));
const srcFile = join(tmpDir, "tiny.mp4");
writeFileSync(srcFile, MP4);
const info = await probeMedia(srcFile);
sourceDurationS = info.durationS ?? 1;
const audioStream = info.streams.find((s) => s.type === "audio");
if (audioStream) {
// Verify sampleRate is populated correctly from ffprobe
expect(audioStream.sampleRate).toBe(44100);
}
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
async function runTool(settings: Record<string, unknown>) {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 },
{ name: "settings", content: JSON.stringify(settings) },
]);
return testApp.app.inject({
method: "POST",
url: "/api/v1/tools/video/video-speed",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
}
describe.skipIf(!ffmpegAvailable())("video-speed (requires ffmpeg)", () => {
it("speeds up by factor 2 and halves duration", async () => {
const res = await runTool({ factor: 2 });
expect(res.statusCode).toBe(200);
const envelope = JSON.parse(res.body);
expect(envelope.downloadUrl).toBeDefined();
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
expect(dl.statusCode).toBe(200);
expect(dl.rawPayload.length).toBeGreaterThan(100);
const tmpDir = mkdtempSync(join(tmpdir(), "speed-test-"));
const probeFile = join(tmpDir, "speed.mp4");
writeFileSync(probeFile, dl.rawPayload);
const info = await probeMedia(probeFile);
expect(info.durationS).not.toBeNull();
const expected = sourceDurationS / 2;
const tolerance = expected * 0.25;
expect(info.durationS as number).toBeGreaterThan(expected - tolerance);
expect(info.durationS as number).toBeLessThan(expected + tolerance);
}, 60_000);
it("speeds up by factor 2 with keepPitch=false and halves duration", async () => {
const res = await runTool({ factor: 2, keepPitch: false });
expect(res.statusCode).toBe(200);
const envelope = JSON.parse(res.body);
expect(envelope.downloadUrl).toBeDefined();
const dl = await testApp.app.inject({ method: "GET", url: envelope.downloadUrl });
expect(dl.statusCode).toBe(200);
expect(dl.rawPayload.length).toBeGreaterThan(100);
const tmpDir = mkdtempSync(join(tmpdir(), "speed-nopitch-"));
const probeFile = join(tmpDir, "speed-nopitch.mp4");
writeFileSync(probeFile, dl.rawPayload);
const info = await probeMedia(probeFile);
expect(info.durationS).not.toBeNull();
const expected = sourceDurationS / 2;
const tolerance = expected * 0.25;
expect(info.durationS as number).toBeGreaterThan(expected - tolerance);
expect(info.durationS as number).toBeLessThan(expected + tolerance);
}, 60_000);
it("rejects factor out of range with 400", async () => {
const res = await runTool({ factor: 8 });
expect(res.statusCode).toBe(400);
});
});
@@ -0,0 +1,86 @@
import { ffmpegAvailable } from "@snapotter/media-engine";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { fixtures, readFixture } from "../../../fixtures/index.js";
import {
buildTestApp,
createMultipartPayload,
loginAsAdmin,
type TestApp,
} from "../../test-server.js";
const MP4 = readFixture(fixtures.video.tiny("mp4"));
let testApp: TestApp;
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
adminToken = await loginAsAdmin(testApp.app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
async function runTool(settings: Record<string, unknown>) {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 },
{ name: "settings", content: JSON.stringify(settings) },
]);
return testApp.app.inject({
method: "POST",
url: "/api/v1/tools/video/video-to-frames",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
}
describe.skipIf(!ffmpegAvailable())("video-to-frames (requires ffmpeg)", () => {
it("extracts every-Nth frame and returns a zip", async () => {
const res = await runTool({ mode: "nth", n: 2 });
expect(res.statusCode).toBe(200);
const envelope = JSON.parse(res.body);
expect(envelope.downloadUrl).toBeDefined();
const dl = await testApp.app.inject({
method: "GET",
url: envelope.downloadUrl,
});
expect(dl.statusCode).toBe(200);
// Verify PK zip magic and reasonable size
const buf = dl.rawPayload;
expect(buf[0]).toBe(0x50); // P
expect(buf[1]).toBe(0x4b); // K
expect(buf[2]).toBe(0x03);
expect(buf[3]).toBe(0x04);
expect(buf.length).toBeGreaterThan(200);
}, 60_000);
it("extracts a single frame at timestamp 0 and returns a zip", async () => {
const res = await runTool({ mode: "timestamps", timestamps: "0" });
expect(res.statusCode).toBe(200);
const envelope = JSON.parse(res.body);
expect(envelope.downloadUrl).toBeDefined();
const dl = await testApp.app.inject({
method: "GET",
url: envelope.downloadUrl,
});
expect(dl.statusCode).toBe(200);
// PK magic
expect(dl.rawPayload[0]).toBe(0x50);
expect(dl.rawPayload[1]).toBe(0x4b);
}, 60_000);
it("rejects timestamps mode with empty timestamps string", async () => {
const res = await runTool({ mode: "timestamps", timestamps: "" });
expect(res.statusCode).toBe(400);
});
it("rejects a timestamp beyond the video duration with 422", async () => {
const res = await runTool({ mode: "timestamps", timestamps: "999" });
expect(res.statusCode).toBe(422);
const body = JSON.parse(res.body);
expect(body.details || body.error).toMatch(/timestamp/i);
});
});
@@ -0,0 +1,64 @@
import { ffmpegAvailable } from "@snapotter/media-engine";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { fixtures, readFixture } from "../../../fixtures/index.js";
import {
buildTestApp,
createMultipartPayload,
loginAsAdmin,
type TestApp,
} from "../../test-server.js";
const MP4 = readFixture(fixtures.video.tiny("mp4"));
let testApp: TestApp;
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
adminToken = await loginAsAdmin(testApp.app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
async function pollJob(jobId: string) {
const { db, schema } = await import("../../../../apps/api/src/db/index.js");
const { eq } = await import("drizzle-orm");
let row: { status: string; outputRefs: unknown } | undefined;
for (let i = 0; i < 120; i++) {
[row] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId));
if (row && ["completed", "failed", "canceled"].includes(row.status)) break;
await new Promise((r) => setTimeout(r, 500));
}
return row;
}
describe.skipIf(!ffmpegAvailable())("video-to-gif (requires ffmpeg)", () => {
it("returns 202 (long hint) and produces a GIF with GIF8 magic", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 },
{ name: "settings", content: JSON.stringify({ fps: 8, width: 120, durationS: 1 }) },
]);
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/tools/video/video-to-gif",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBe(202);
const { jobId } = JSON.parse(res.body);
const row = await pollJob(jobId);
expect(row?.status).toBe("completed");
const outName = (row?.outputRefs as string[])[0].split("/").pop() as string;
expect(outName.endsWith(".gif")).toBe(true);
const dl = await testApp.app.inject({
method: "GET",
url: `/api/v1/download/${jobId}/${encodeURIComponent(outName)}`,
});
expect(dl.statusCode).toBe(200);
// GIF files start with GIF8 magic bytes
const magic = dl.rawPayload.subarray(0, 4).toString("ascii");
expect(magic).toBe("GIF8");
}, 90_000);
});
@@ -0,0 +1,59 @@
import { ffmpegAvailable } from "@snapotter/media-engine";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { fixtures, readFixture } from "../../../fixtures/index.js";
import {
buildTestApp,
createMultipartPayload,
loginAsAdmin,
type TestApp,
} from "../../test-server.js";
const MP4 = readFixture(fixtures.video.tiny("mp4"));
let testApp: TestApp;
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
adminToken = await loginAsAdmin(testApp.app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
async function runTool(settings: Record<string, unknown>) {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 },
{ name: "settings", content: JSON.stringify(settings) },
]);
return testApp.app.inject({
method: "POST",
url: "/api/v1/tools/video/video-to-webp",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
}
describe.skipIf(!ffmpegAvailable())("video-to-webp (requires ffmpeg)", () => {
it("converts video to animated webp with RIFF/WEBP magic", async () => {
const res = await runTool({});
expect(res.statusCode).toBe(200);
const envelope = JSON.parse(res.body);
expect(envelope.downloadUrl).toBeDefined();
const dl = await testApp.app.inject({
method: "GET",
url: envelope.downloadUrl,
});
expect(dl.statusCode).toBe(200);
expect(dl.rawPayload.length).toBeGreaterThan(100);
// Verify RIFF....WEBP magic bytes
const buf = dl.rawPayload;
const riff = buf.subarray(0, 4).toString("ascii");
const webp = buf.subarray(8, 12).toString("ascii");
expect(riff).toBe("RIFF");
expect(webp).toBe("WEBP");
}, 60_000);
});
@@ -0,0 +1,85 @@
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ffmpegAvailable, probeMedia } from "@snapotter/media-engine";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { fixtures, readFixture } from "../../../fixtures/index.js";
import {
buildTestApp,
createMultipartPayload,
loginAsAdmin,
type TestApp,
} from "../../test-server.js";
const MP4 = readFixture(fixtures.video.tiny("mp4"));
let testApp: TestApp;
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
adminToken = await loginAsAdmin(testApp.app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
async function runTool(settings: Record<string, unknown>) {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "tiny.mp4", contentType: "video/mp4", content: MP4 },
{ name: "settings", content: JSON.stringify(settings) },
]);
return testApp.app.inject({
method: "POST",
url: "/api/v1/tools/video/watermark-video",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
}
describe.skipIf(!ffmpegAvailable())("watermark-video (requires ffmpeg)", () => {
it("watermarks a video and returns 200 with video content type", async () => {
const res = await runTool({ text: "CONFIDENTIAL" });
expect(res.statusCode).toBe(200);
const envelope = JSON.parse(res.body);
expect(envelope.downloadUrl).toBeDefined();
const dl = await testApp.app.inject({
method: "GET",
url: envelope.downloadUrl,
});
expect(dl.statusCode).toBe(200);
expect(dl.rawPayload.length).toBeGreaterThan(100);
const tmpDir = mkdtempSync(join(tmpdir(), "wm-test-"));
const probeFile = join(tmpDir, "watermarked.mp4");
writeFileSync(probeFile, dl.rawPayload);
const info = await probeMedia(probeFile);
expect(info.streams.some((s) => s.type === "video")).toBe(true);
}, 60_000);
it("handles special characters and literal percent sequences in text", async () => {
const res = await runTool({ text: "a:b'c\\d,e %{pts} %{bad}" });
expect(res.statusCode).toBe(200);
const envelope = JSON.parse(res.body);
expect(envelope.downloadUrl).toBeDefined();
const dl = await testApp.app.inject({
method: "GET",
url: envelope.downloadUrl,
});
expect(dl.statusCode).toBe(200);
const tmpDir = mkdtempSync(join(tmpdir(), "wm-special-"));
const probeFile = join(tmpDir, "watermarked.mp4");
writeFileSync(probeFile, dl.rawPayload);
const info = await probeMedia(probeFile);
expect(info.streams.some((s) => s.type === "video")).toBe(true);
}, 60_000);
it("rejects empty text with 400", async () => {
const res = await runTool({ text: "" });
expect(res.statusCode).toBe(400);
});
});