mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user