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:
Claude 2026-10-03 14:01:58 +00:00
parent 4e545c20f4
commit 97cede6c9c
No known key found for this signature in database
2 changed files with 215 additions and 0 deletions

View 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: [],
});
});
});

View File

@ -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",