diff --git a/packages/adapters/src/media/video-operations.postgres.test.ts b/packages/adapters/src/media/video-operations.postgres.test.ts new file mode 100644 index 0000000..7f5592d --- /dev/null +++ b/packages/adapters/src/media/video-operations.postgres.test.ts @@ -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; + 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 { + 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: [], + }); + }); +}); diff --git a/packages/testkit/src/cli/harness.ts b/packages/testkit/src/cli/harness.ts index fc38326..ce36780 100644 --- a/packages/testkit/src/cli/harness.ts +++ b/packages/testkit/src/cli/harness.ts @@ -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",