test(media): run video edits and exports through the media runtime
A Postgres suite starts edit_video and export_video through the real runtime and local artifact store: outputs become linked versions with posters on the card, exports store one version per preset, appended clips and subtitle files of another user are refused at start, and an out-of-range cut fails the job with a readable error. It skips without Postgres or ffmpeg. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
parent
4e545c20f4
commit
97cede6c9c
214
packages/adapters/src/media/video-operations.postgres.test.ts
Normal file
214
packages/adapters/src/media/video-operations.postgres.test.ts
Normal file
@ -0,0 +1,214 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { JobPublisher } from "@rakazo/adapter-kit";
|
||||
import type { MediaJobBlock, MessageBlock } from "@rakazo/contracts";
|
||||
import type { PrismaClient } from "@rakazo/db";
|
||||
import { createDb } from "@rakazo/db";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { storeArtifactFromFile } from "../artifact-files.js";
|
||||
import { LocalArtifactStore } from "../artifacts.js";
|
||||
import { createMediaRuntime, MediaJobRejectedError } from "../media-runtime.js";
|
||||
import { detectFfmpeg } from "./ffmpeg.js";
|
||||
import type { TestClips } from "./media-test-support.js";
|
||||
import { createTestClips } from "./media-test-support.js";
|
||||
import { createVideoOperations } from "./video-operations.js";
|
||||
|
||||
// CI runners may not have ffmpeg; the suite runs wherever both it and Postgres are available.
|
||||
const describePostgres =
|
||||
process.env.VERIFY_DATABASE && process.env.DATABASE_URL && (await detectFfmpeg())
|
||||
? describe.sequential
|
||||
: describe.skip;
|
||||
|
||||
describePostgres("video operations in the media runtime (PostgreSQL + ffmpeg)", () => {
|
||||
let db: ReturnType<typeof createDb>;
|
||||
let prisma: PrismaClient;
|
||||
let root: string;
|
||||
let clips: TestClips;
|
||||
const users: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
db = createDb(process.env.DATABASE_URL!);
|
||||
prisma = db.prisma;
|
||||
root = await mkdtemp(join(tmpdir(), "video-operations-pg-"));
|
||||
clips = await createTestClips();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!db) return;
|
||||
await prisma.mediaJob.deleteMany({ where: { userId: { in: users } } });
|
||||
await prisma.artifact.deleteMany({ where: { userId: { in: users } } });
|
||||
await prisma.organization.deleteMany({ where: { id: { in: users } } });
|
||||
await prisma.user.deleteMany({ where: { id: { in: users } } });
|
||||
await prisma.$disconnect();
|
||||
await db.pool.end();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
await rm(clips.dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function setup() {
|
||||
const id = randomUUID();
|
||||
users.push(id);
|
||||
await prisma.user.create({
|
||||
data: { id, name: "Video test", email: `${id}@example.test`, emailVerified: true },
|
||||
});
|
||||
await prisma.organization.create({
|
||||
data: { id, name: "Video test", slug: id, createdAt: new Date() },
|
||||
});
|
||||
await prisma.space.create({ data: { id, organizationId: id, name: "Media", isDefault: true } });
|
||||
await prisma.member.create({
|
||||
data: { id, organizationId: id, userId: id, role: "owner", createdAt: new Date() },
|
||||
});
|
||||
const bot = await prisma.bot.create({
|
||||
data: { spaceId: id, userId: id, name: "Studio", color: "test-color" },
|
||||
});
|
||||
const thread = await prisma.thread.create({ data: { spaceId: id, userId: id, botId: bot.id } });
|
||||
const jobs: JobPublisher = {
|
||||
enqueue: async () => undefined,
|
||||
cancel: async () => undefined,
|
||||
close: async () => undefined,
|
||||
};
|
||||
const artifacts = new LocalArtifactStore(join(root, id, "artifacts"));
|
||||
const runtime = createMediaRuntime(
|
||||
{
|
||||
prisma,
|
||||
events: { notify: async () => undefined },
|
||||
jobs,
|
||||
artifacts,
|
||||
registrations: createVideoOperations(),
|
||||
},
|
||||
{ tempRoot: join(root, id, "jobs") },
|
||||
);
|
||||
const scope = {
|
||||
spaceId: id,
|
||||
userId: id,
|
||||
botId: bot.id,
|
||||
groupId: null,
|
||||
threadId: thread.id,
|
||||
runId: null,
|
||||
};
|
||||
const store = async (path: string, name: string, mimeType: string) =>
|
||||
(
|
||||
await storeArtifactFromFile(
|
||||
{ prisma, artifacts },
|
||||
{
|
||||
path,
|
||||
name,
|
||||
mimeType,
|
||||
spaceId: id,
|
||||
userId: id,
|
||||
botId: bot.id,
|
||||
groupId: null,
|
||||
runId: null,
|
||||
},
|
||||
)
|
||||
).id;
|
||||
return { runtime, scope, store };
|
||||
}
|
||||
|
||||
async function card(messageId: string, jobId: string): Promise<MediaJobBlock | undefined> {
|
||||
const message = await prisma.message.findUniqueOrThrow({ where: { id: messageId } });
|
||||
return (message.blocks as MessageBlock[]).find(
|
||||
(block): block is MediaJobBlock => block.kind === "media" && block.jobId === jobId,
|
||||
);
|
||||
}
|
||||
|
||||
it("edits a stored video into a linked 9:16 version with a poster on the card", async () => {
|
||||
const { runtime, scope, store } = await setup();
|
||||
const source = await store(clips.landscape, "holiday.mp4", "video/mp4");
|
||||
const subtitles = await store(clips.srt, "holiday.srt", "application/x-subrip");
|
||||
const started = await runtime.start({
|
||||
...scope,
|
||||
operation: "edit_video",
|
||||
input: {
|
||||
artifact_id: source,
|
||||
title: "Urlaub 9:16",
|
||||
steps: [
|
||||
{ op: "trim", start: 0.5, end: 2.5 },
|
||||
{ op: "reframe", preset: "vertical", fit: "blur" },
|
||||
{ op: "subtitles", artifact_id: subtitles },
|
||||
],
|
||||
},
|
||||
});
|
||||
await runtime.runJob(started.jobId);
|
||||
const job = await runtime.get(started.jobId);
|
||||
expect(job).toMatchObject({ status: "done", title: "Urlaub 9:16", progress: 1 });
|
||||
const [output] = job!.outputs;
|
||||
expect(output).toMatchObject({
|
||||
kind: "video",
|
||||
mimeType: "video/mp4",
|
||||
name: "Urlaub 9 16.mp4",
|
||||
width: 1080,
|
||||
height: 1920,
|
||||
parentArtifactId: source,
|
||||
});
|
||||
expect(output?.posterArtifactId).toBeDefined();
|
||||
const poster = await prisma.artifact.findUniqueOrThrow({
|
||||
where: { id: output!.posterArtifactId! },
|
||||
});
|
||||
expect(poster.mimeType).toBe("image/jpeg");
|
||||
const stored = await prisma.artifact.findUniqueOrThrow({ where: { id: output!.artifactId } });
|
||||
expect(stored).toMatchObject({ parentArtifactId: source, width: 1080, height: 1920 });
|
||||
expect(Math.abs((stored.durationMs ?? 0) - 2000)).toBeLessThan(150);
|
||||
expect(await card(started.messageId, started.jobId)).toMatchObject({
|
||||
status: "done",
|
||||
outputs: job?.outputs,
|
||||
});
|
||||
});
|
||||
|
||||
it("exports one stored version per preset", async () => {
|
||||
const { runtime, scope, store } = await setup();
|
||||
const source = await store(clips.landscape, "holiday.mp4", "video/mp4");
|
||||
const started = await runtime.start({
|
||||
...scope,
|
||||
operation: "export_video",
|
||||
input: { artifact_id: source, presets: ["square", "portrait"] },
|
||||
});
|
||||
await runtime.runJob(started.jobId);
|
||||
const job = await runtime.get(started.jobId);
|
||||
expect(job).toMatchObject({ status: "done", title: "1:1 · 4:5" });
|
||||
expect(
|
||||
job?.outputs.map((output) => [output.name, output.preset, output.width, output.height]),
|
||||
).toEqual([
|
||||
["holiday-1x1.mp4", "square", 1080, 1080],
|
||||
["holiday-4x5.mp4", "portrait", 1080, 1350],
|
||||
]);
|
||||
});
|
||||
|
||||
it("refuses appended clips and subtitles the user cannot read", async () => {
|
||||
const { runtime, scope, store } = await setup();
|
||||
const other = await setup();
|
||||
const source = await store(clips.landscape, "mine.mp4", "video/mp4");
|
||||
const foreignClip = await other.store(clips.silentPortrait, "theirs.mp4", "video/mp4");
|
||||
const foreignSubtitles = await other.store(clips.srt, "theirs.srt", "application/x-subrip");
|
||||
for (const step of [
|
||||
{ op: "concat", artifact_ids: [foreignClip] },
|
||||
{ op: "subtitles", artifact_id: foreignSubtitles },
|
||||
]) {
|
||||
await expect(
|
||||
runtime.start({
|
||||
...scope,
|
||||
operation: "edit_video",
|
||||
input: { artifact_id: source, title: "x", steps: [step] },
|
||||
}),
|
||||
).rejects.toThrow(MediaJobRejectedError);
|
||||
}
|
||||
});
|
||||
|
||||
it("fails the job with a readable error when a range is outside the video", async () => {
|
||||
const { runtime, scope, store } = await setup();
|
||||
const source = await store(clips.landscape, "short.mp4", "video/mp4");
|
||||
const started = await runtime.start({
|
||||
...scope,
|
||||
operation: "edit_video",
|
||||
input: { artifact_id: source, title: "x", steps: [{ op: "trim", start: 10, end: 20 }] },
|
||||
});
|
||||
await runtime.runJob(started.jobId);
|
||||
expect(await runtime.get(started.jobId)).toMatchObject({
|
||||
status: "failed",
|
||||
error: "trim 10–20 s is outside the 3 s video.",
|
||||
outputs: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -118,6 +118,7 @@ async function main() {
|
||||
"packages/adapters/src/job-reconciler.postgres.test.ts",
|
||||
"packages/adapters/src/cloud-agent.postgres.test.ts",
|
||||
"packages/adapters/src/media/image-operations.postgres.test.ts",
|
||||
"packages/adapters/src/media/video-operations.postgres.test.ts",
|
||||
"packages/adapters/src/media-runtime.postgres.test.ts",
|
||||
"packages/adapters/src/builtin-memory-provider.postgres.test.ts",
|
||||
"packages/adapters/src/profile-consolidation.postgres.test.ts",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user