From 03e4961c9f2b5f6823a9bba1f794cac8d932d1d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 18:43:37 +0000 Subject: [PATCH] test(media): cover marketing plans, brand kits, bundles, publishing and learning --- packages/adapters/src/media-marketing-copy.ts | 22 +- .../src/media-marketing.postgres.test.ts | 941 ++++++++++++++++++ packages/adapters/src/media-marketing.test.ts | 350 +++++++ packages/adapters/src/media-zip.test.ts | 108 ++ 4 files changed, 1417 insertions(+), 4 deletions(-) create mode 100644 packages/adapters/src/media-marketing.postgres.test.ts create mode 100644 packages/adapters/src/media-marketing.test.ts create mode 100644 packages/adapters/src/media-zip.test.ts diff --git a/packages/adapters/src/media-marketing-copy.ts b/packages/adapters/src/media-marketing-copy.ts index f8ed067..4c43890 100644 --- a/packages/adapters/src/media-marketing-copy.ts +++ b/packages/adapters/src/media-marketing-copy.ts @@ -149,16 +149,30 @@ export async function generateCopy( .filter((chapter) => chapter.atMs < durationMs && chapter.title.trim()) .sort((a, b) => a.atMs - b.atMs) : []; + const hook = truncateText(draft.hook, 200); + const description = draft.description ? truncateText(draft.description, 5000) : undefined; + const altText = draft.altText?.trim() ? truncateText(draft.altText, 500) : undefined; + // The other fields get the same banned-pattern and link checks as the caption. + const extraIssues = checkCopy(profile, { + title: null, + caption: [hook, description, altText].filter(Boolean).join("\n"), + tags: [], + }) + .filter((issue) => issue.code === "banned_pattern" || issue.code.startsWith("link_")) + .map((issue) => ({ + ...issue, + message: issue.message.replace(/^caption/, "hook or description"), + })); return MarketingCopySchema.parse({ profileId: profile.id, language, title, - hook: truncateText(draft.hook, 200), + hook, caption, hashtags, - ...(draft.description ? { description: truncateText(draft.description, 5000) } : {}), + ...(description ? { description } : {}), ...(chapterList.length > 0 ? { chapters: chapterList } : {}), - ...(draft.altText?.trim() ? { altText: truncateText(draft.altText, 500) } : {}), - issues: checkCopy(profile, { title, caption, tags: hashtags }), + ...(altText ? { altText } : {}), + issues: [...checkCopy(profile, { title, caption, tags: hashtags }), ...extraIssues], }); } diff --git a/packages/adapters/src/media-marketing.postgres.test.ts b/packages/adapters/src/media-marketing.postgres.test.ts new file mode 100644 index 0000000..892847c --- /dev/null +++ b/packages/adapters/src/media-marketing.postgres.test.ts @@ -0,0 +1,941 @@ +import { execFile, execFileSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import type { BackgroundJob, JobPublisher, MediaRenderer } from "@rakazo/adapter-kit"; +import type { BrandKit, FfprobeReport, PlatformProfile, TeaserWeights } from "@rakazo/contracts"; +import { PlatformProfileSchema, TEASER_FEATURES } from "@rakazo/contracts"; +import type { MediaRepos, PrismaClient } from "@rakazo/db"; +import { bootstrapUserSpace, createDb, createMediaRepos } from "@rakazo/db"; +import type { ProfileSet } from "@rakazo/media"; +import sharp from "sharp"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { LocalArtifactStore } from "./artifacts.js"; +import { isCubeLut, saveBrandKit } from "./media-brand.js"; +import type { + MediaActor, + MediaModelClient, + MediaRealtimeEvent, + MediaServiceDeps, +} from "./media-context.js"; +import { EmulatorMediaInferenceProvider } from "./media-emulator.js"; +import { createMediaMarketingJobHandlers, createMediaMarketingService } from "./media-marketing.js"; +import { storeMediaArtifact } from "./media-marketing-files.js"; +import { recordMetric, refitTeaserModel, resetTeaserModel } from "./media-metrics.js"; +import { ResvgMotionGraphicsRenderer } from "./media-motion-renderer.js"; +import { bundleRelativeDir } from "./media-publish.js"; +import { BundleMediaPublisher } from "./media-publisher-bundle.js"; + +/* + * Marketing slice against real repositories: plans with the teaser judge, + * brand kits and intro variants, thumbnails, publications, bundles, the + * scheduled publish job, and results import with teaser learning. All media is + * synthetic (ffmpeg testsrc and sine); the model is a recording fake. + */ + +const run = promisify(execFile); +const databaseUrl = process.env.DATABASE_URL; +const describePostgres = + process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip; + +function onPath(bin: string): boolean { + try { + execFileSync(bin, ["-version"], { stdio: "ignore" }); + return true; + } catch { + return false; + } +} +const hasFfmpeg = onPath("ffmpeg") && onPath("ffprobe"); + +async function ffmpeg(args: string[]): Promise { + await run("ffmpeg", ["-hide_banner", "-loglevel", "error", "-nostdin", "-y", ...args]); +} + +function profile(overrides: Record): PlatformProfile { + return PlatformProfileSchema.parse({ + id: "example-short-vertical", + name: "Example short vertical", + kind: "free_social", + reviewedAt: "2026-09-01", + video: { + aspectRatios: ["9:16"], + width: 1080, + height: 1920, + fps: 30, + codec: "h264", + maxBitrateKbps: 8000, + maxFileSizeMb: 500, + }, + duration: { hardMinMs: 1000, hardMaxMs: 60_000, recommended: [7000, 15_000, 30_000] }, + safeZones: { topPct: 10, bottomPct: 20, leftPct: 5, rightPct: 12 }, + captions: { recommended: true, maxLines: 2 }, + audio: {}, + text: { titleMaxChars: 100, captionMaxChars: 150, hashtagLimit: 3, linkPolicy: "bio_only" }, + content: { thresholds: { explicit_nudity: 0.2 } }, + disclosure: { aiAlteredLabelRequired: true }, + consent: { requirePerformerConsent: false, requireAgeVerification: false }, + export: {}, + ...overrides, + }); +} + +const SHORT = profile({}); +const LANDSCAPE = profile({ + id: "example-landscape", + name: "Example landscape", + video: { + aspectRatios: ["16:9"], + width: 1920, + height: 1080, + fps: 30, + codec: "h264", + maxBitrateKbps: 12_000, + maxFileSizeMb: 2000, + }, + duration: { hardMinMs: 1000, hardMaxMs: 600_000, recommended: [30_000], hookWindowMs: 5000 }, + text: { titleMaxChars: 100, captionMaxChars: 5000, hashtagLimit: 15, linkPolicy: "allowed" }, +}); +const PAID = profile({ + id: "example-paid-video", + name: "Example paid video", + kind: "paid_video", + video: LANDSCAPE.video, + duration: { hardMinMs: 1000, hardMaxMs: 3_600_000, recommended: [] }, + text: { captionMaxChars: 2000, hashtagLimit: 5, linkPolicy: "forbidden" }, + disclosure: { aiAlteredLabelRequired: true, ageRestrictedFlag: true }, + consent: { requirePerformerConsent: true, requireAgeVerification: true }, + export: { previewClipMs: 2000 }, +}); + +const profileSet: ProfileSet = { + profiles: new Map( + [SHORT, LANDSCAPE, PAID].map((p) => [p.id, { profile: p, origin: "builtin" as const }]), + ), + errors: [], +}; + +const KIT: BrandKit = { + name: "Lake Studio", + colors: { + primary: "#1a1a1a", + secondary: "#333333", + accent: "#777777", + text: "#f5f5f5", + background: "#0a0a0a", + }, + fonts: [], + logoArtifactIds: [], + captionPresets: [], + musicBeds: [], + tone: "calm and premium", + intro: { templateId: "intro_logo_reveal", params: {}, durationMs: 2500 }, +}; + +function gatePassed(profileId: string) { + return { + profileId, + passed: true, + checkedAt: new Date().toISOString(), + sampledFrames: 8, + issues: [], + autoFixRound: 0, + }; +} + +describePostgres("media marketing on Postgres", () => { + let prisma: PrismaClient; + let pool: { end(): Promise }; + let repos: MediaRepos; + let root: string; + let mediaRoot: string; + const userIds: string[] = []; + const jobs: BackgroundJob[] = []; + const canceledJobs: string[] = []; + const events: MediaRealtimeEvent[] = []; + let modelReply: ((input: Parameters[0]) => unknown) | null = null; + const modelCalls: Array[0]> = []; + let deps: MediaServiceDeps; + + async function newActor(): Promise { + const id = `mk${randomUUID().replaceAll("-", "")}`; + userIds.push(id); + await prisma.user.create({ data: { id, name: "Test", email: `${id}@rakazo.test` } }); + const { spaceId } = await bootstrapUserSpace( + prisma, + { id }, + { signupsEnabled: "true", signupAllowlist: "" }, + { claimDeploymentOwner: false }, + ); + return { spaceId, userId: id }; + } + + const renderer: MediaRenderer = { + id: "test-ffmpeg", + hardwareEncoders: async () => [], + render: async () => { + throw new Error("not used by the marketing slice"); + }, + probe: async () => ({ durationMs: 4000 }) as FfprobeReport, + async extractFrame(input) { + await ffmpeg([ + "-ss", + (input.tMs / 1000).toFixed(3), + "-i", + input.path, + "-frames:v", + "1", + ...(input.width ? ["-vf", `scale=${input.width}:-2`] : []), + "-map_metadata", + "-1", + input.outPath, + ]); + }, + }; + + beforeAll(async () => { + const db = createDb(databaseUrl as string, { poolMax: 4 }); + prisma = db.prisma; + pool = db.pool; + repos = createMediaRepos(prisma); + root = await mkdtemp(path.join(tmpdir(), "media-marketing-")); + mediaRoot = path.join(root, "media"); + await mkdir(mediaRoot, { recursive: true }); + const jobPublisher: JobPublisher = { + enqueue: async (job) => { + jobs.push(job); + }, + cancel: async (key) => { + canceledJobs.push(key); + }, + close: async () => {}, + }; + deps = { + prisma, + repos, + jobs: jobPublisher, + inference: new EmulatorMediaInferenceProvider({ root: mediaRoot }), + renderer, + motion: new ResvgMotionGraphicsRenderer({ root: mediaRoot }), + publisher: new BundleMediaPublisher(), + shortener: null, + sources: {}, + mediaRoot, + profiles: async () => profileSet, + artifacts: new LocalArtifactStore(path.join(root, "artifacts")), + model: { + async generate(input) { + modelCalls.push(input); + return modelReply ? input.schema.parse(modelReply(input)) : null; + }, + }, + publish: async (_spaceId, event) => { + events.push(event); + }, + now: () => new Date(), + }; + }); + + afterAll(async () => { + if (!prisma) return; + const memberships = await prisma.member.findMany({ + where: { userId: { in: userIds } }, + select: { organizationId: true }, + }); + await prisma.organization.deleteMany({ + where: { id: { in: memberships.map((member) => member.organizationId) } }, + }); + await prisma.user.deleteMany({ where: { id: { in: userIds } } }); + await prisma.$disconnect(); + await pool.end(); + await rm(root, { recursive: true, force: true }); + }); + + /** A space with two 20 s landscape clips, scored segments, and a project. */ + async function seedLibrary(actor: MediaActor, options: { kit?: BrandKit } = {}) { + const source = await repos.createSource(actor.spaceId, { + kind: "folder", + label: "Footage", + ownerUserId: actor.userId, + config: { path: "/media/footage" }, + }); + const item = (index: number) => ({ + externalId: `clip-${index}.mp4`, + kind: "video" as const, + capturedAt: new Date(Date.UTC(2026, 0, 1, 8, index)), + durationMs: 20_000, + width: 1920, + height: 1080, + fps: 30, + gpsLat: null, + gpsLon: null, + deviceModel: null, + albumNames: [], + isFavorite: false, + fileName: `clip-${index}.mp4`, + sizeBytes: 1000, + revision: "r1", + }); + const synced = await repos.upsertSourceItems(actor.spaceId, source.id, [item(1), item(2)]); + const [assetA, assetB] = synced.changedAssetIds as [string, string]; + const scores = (motion: number, speechHook = 0.2) => ({ + motion, + sharpness: 0.7, + faces: 0.2, + audioPeak: 0.4, + speechHook, + novelty: 0.5, + aesthetics: 0.6, + }); + await repos.replaceSegments(actor.spaceId, assetA, 1, [ + { + kind: "shot", + startMs: 0, + endMs: 3000, + scores: scores(0.9, 0.9), + transcriptExcerpt: "Did you know these 3 tricks?", + }, + { kind: "shot", startMs: 3000, endMs: 8000, scores: scores(0.6) }, + { kind: "shot", startMs: 8000, endMs: 12_000, scores: scores(1, 1), paidOnly: true }, + { kind: "shot", startMs: 12_000, endMs: 16_000, scores: scores(0.95) }, + { kind: "shot", startMs: 16_000, endMs: 20_000, scores: scores(0.5) }, + ]); + await repos.replaceSegments(actor.spaceId, assetB, 1, [ + { kind: "shot", startMs: 0, endMs: 5000, scores: scores(0.85) }, + { kind: "shot", startMs: 5000, endMs: 10_000, scores: scores(0.4) }, + ]); + // Frame 12.5 s of clip A exceeds the short profile's nudity threshold. + const frames = Array.from({ length: 20 }, (_, i) => ({ + tMs: i * 1000 + 500, + scores: { explicit_nudity: i === 12 ? 0.9 : 0.01 }, + })); + for (const assetId of [assetA, assetB]) { + await repos.upsertAnalysis(actor.spaceId, assetId, { + kind: "classifier", + modelId: "emulator", + modelVersion: "1", + data: { frames }, + }); + } + // A treated tattoo on clip B keeps every frame of it away from the model. + await repos.createMark(actor.spaceId, { + kind: "tattoo", + label: "arm", + personId: null, + assetId: assetB, + prompt: { tMs: 1000, point: { x: 0.5, y: 0.5 } }, + treatment: "blur", + }); + if (hasFfmpeg) { + for (const assetId of [assetA, assetB]) { + const relative = path.join(actor.spaceId, assetId, "proxy.mp4"); + await mkdir(path.join(mediaRoot, path.dirname(relative)), { recursive: true }); + await ffmpeg([ + "-f", + "lavfi", + "-i", + "testsrc2=s=160x90:r=10:d=20", + "-c:v", + "libx264", + "-preset", + "ultrafast", + "-pix_fmt", + "yuv420p", + path.join(mediaRoot, relative), + ]); + await repos.recordProxy(actor.spaceId, assetId, { + contentHash: assetId.padEnd(64, "0").slice(0, 64), + proxyPath: relative, + thumbPath: null, + }); + } + } + const kit = options.kit ? (await saveBrandKit(deps, actor, { kit: options.kit })).record : null; + const project = await repos.createProject(actor.spaceId, actor.userId, { + title: "Lake Day Launch", + goal: "teaser", + profileIds: [SHORT.id, LANDSCAPE.id], + assetIds: [assetA, assetB], + brandKitId: kit?.id ?? null, + }); + const segments = await repos.listSegments(actor.spaceId, [assetA, assetB]); + return { assetA, assetB, project, segments }; + } + + describe("edit plans", () => { + it("puts the hook first on shorts, keeps paid-only segments out, and ends on a CTA", async () => { + const actor = await newActor(); + const { project, segments, assetA } = await seedLibrary(actor, { kit: KIT }); + modelReply = null; + const marketing = createMediaMarketingService(deps); + const plans = await marketing.autoPlan(actor, { projectId: project.id }); + expect(plans.map((plan) => plan.profileId).sort()).toEqual([LANDSCAPE.id, SHORT.id]); + const short = plans.find((plan) => plan.profileId === SHORT.id)!; + const landscape = plans.find((plan) => plan.profileId === LANDSCAPE.id)!; + + // Shorts: hook before branding; the long-form profile may open with the intro. + expect(short.edl.intro).toBeUndefined(); + expect(landscape.edl.intro?.templateId).toBe("intro_logo_reveal"); + const firstShort = [...short.edl.tracks.video].sort((a, b) => a.atMs - b.atMs)[0]!; + expect(firstShort.atMs).toBeLessThan(SHORT.duration.hookWindowMs); + expect(short.rationale).toContain("Brand intro left out so the hook comes first."); + + const paid = segments.find((segment) => segment.paidOnly)!; + for (const plan of plans) { + for (const clip of plan.edl.tracks.video) { + expect(clip.segmentId).not.toBe(paid.id); + if (clip.assetId === assetA) { + expect(clip.outMs <= paid.startMs || clip.inMs >= paid.endMs).toBe(true); + } + } + } + const cta = short.edl.overlays.find((overlay) => overlay.kind === "cta_end_card"); + expect(cta && "text" in cta ? cta.text : null).toBe("Link in bio"); + expect(short.rationale).toContain("Default teaser weights."); + }); + + it("sends only gate-safe candidates to the model and applies its ranking", async () => { + if (!hasFfmpeg) return; + const actor = await newActor(); + const { project, segments, assetB } = await seedLibrary(actor); + modelCalls.length = 0; + modelReply = (input) => { + const ids = [...input.prompt.matchAll(/id=(\S+)/g)].map((match) => match[1]!); + return { + ranking: ids.reverse().map((segmentId) => ({ segmentId, reason: "clear opener" })), + }; + }; + const plans = await createMediaMarketingService(deps).autoPlan(actor, { + projectId: project.id, + profileIds: [SHORT.id], + }); + modelReply = null; + expect(modelCalls).toHaveLength(1); + const call = modelCalls[0]!; + const sentIds = [...call.prompt.matchAll(/id=(\S+)/g)].map((match) => match[1]!); + const byId = new Map(segments.map((segment) => [segment.id, segment])); + expect(sentIds.length).toBeGreaterThanOrEqual(2); + expect(call.images?.map((image) => image.name)).toEqual(sentIds.map((id) => `${id}.png`)); + for (const id of sentIds) { + const segment = byId.get(id)!; + expect(segment.paidOnly).toBe(false); + expect(segment.assetId).not.toBe(assetB); + expect(segment.startMs).not.toBe(12_000); + } + expect(call.prompt).not.toContain(actor.userId); + expect(plans[0]?.rationale).toContain("Model ranking applied"); + }); + + it("uses learned weights only from 20 publications and validates submitted plans", async () => { + const actor = await newActor(); + const { project, assetA } = await seedLibrary(actor); + const marketing = createMediaMarketingService(deps); + const weights = Object.fromEntries(TEASER_FEATURES.map((f) => [f, 1])) as TeaserWeights; + await repos.putTeaserModel(actor.spaceId, weights, 5); + const [few] = await marketing.autoPlan(actor, { + projectId: project.id, + profileIds: [SHORT.id], + }); + expect(few?.rationale).toContain("Default teaser weights."); + await repos.putTeaserModel(actor.spaceId, weights, 20); + const [many] = await marketing.autoPlan(actor, { + projectId: project.id, + profileIds: [SHORT.id], + }); + expect(many?.rationale).toContain("Weights learned"); + await resetTeaserModel(deps, actor); + expect(await repos.getTeaserModel(actor.spaceId)).toBeNull(); + + const wrongProfile = await marketing.submitPlan(actor, { + projectId: project.id, + edl: { ...many!.edl, output: { ...many!.edl.output, profileId: PAID.id } }, + rationale: null, + createdBy: "agent", + }); + expect(wrongProfile.ok).toBe(false); + const outOfRange = await marketing.submitPlan(actor, { + projectId: project.id, + edl: { + ...many!.edl, + tracks: { + ...many!.edl.tracks, + video: [ + { id: "c1", assetId: assetA, inMs: 0, outMs: 90_000, atMs: 0, reframe: "none" }, + ], + }, + }, + rationale: null, + createdBy: "user", + }); + expect(outOfRange.ok).toBe(false); + const resubmitted = await marketing.submitPlan(actor, { + projectId: project.id, + edl: many!.edl, + rationale: "kept", + createdBy: "user", + }); + expect(resubmitted.ok && resubmitted.plan.version).toBe(3); + }); + }); + + describe("brand kits and intros", () => { + it("accepts only files of the space with usable types and real templates", async () => { + const actor = await newActor(); + const other = await newActor(); + const png = await sharp({ + create: { + width: 64, + height: 64, + channels: 4, + background: { r: 255, g: 255, b: 255, alpha: 1 }, + }, + }) + .png() + .toBuffer(); + const logo = await storeMediaArtifact(deps, actor, { + name: "logo.png", + mimeType: "image/png", + bytes: new Uint8Array(png), + }); + const lutText = `LUT_3D_SIZE 2\n${Array.from({ length: 8 }, () => "0 0.5 1").join("\n")}\n`; + expect(isCubeLut(lutText)).toBe(true); + expect(isCubeLut("LUT_3D_SIZE 2\n0 0 0\n")).toBe(false); + const lut = await storeMediaArtifact(deps, actor, { + name: "look.cube", + mimeType: "text/plain", + bytes: new TextEncoder().encode(lutText), + }); + const foreign = await storeMediaArtifact(deps, other, { + name: "logo.png", + mimeType: "image/png", + bytes: new Uint8Array(png), + }); + + const saved = await saveBrandKit(deps, actor, { + kit: { + ...KIT, + logoArtifactIds: [logo], + lutArtifactId: lut, + colors: { ...KIT.colors, text: "#111111" }, + }, + }); + expect(saved.record.kit.logoArtifactIds).toEqual([logo]); + expect(saved.warnings.some((warning) => warning.includes("contrast"))).toBe(true); + + await expect( + saveBrandKit(deps, actor, { kit: { ...KIT, logoArtifactIds: [foreign] } }), + ).rejects.toThrow(/not found in this space/); + await expect( + saveBrandKit(deps, actor, { kit: { ...KIT, logoArtifactIds: [lut] } }), + ).rejects.toThrow(/PNG or SVG/); + await expect( + saveBrandKit(deps, actor, { + kit: { ...KIT, intro: { templateId: "endcard_cta", params: {}, durationMs: 3000 } }, + }), + ).rejects.toThrow(/not a intro template/); + await expect( + saveBrandKit(deps, actor, { + kit: { ...KIT, intro: { templateId: "intro_glitch", params: {}, durationMs: 5000 } }, + }), + ).rejects.toThrow(/duration/); + }); + + it("proposes three intro variants with previews", async () => { + const actor = await newActor(); + const { record } = await saveBrandKit(deps, actor, { kit: KIT }); + const variants = await createMediaMarketingService(deps).introVariants(actor, { + brandKitId: record.id, + count: 3, + }); + expect(variants).toHaveLength(3); + expect(new Set(variants.map((v) => v.template.templateId)).size).toBe(3); + for (const variant of variants) { + expect(variant.template.durationMs).toBeGreaterThanOrEqual(1500); + expect(variant.template.durationMs).toBeLessThanOrEqual(4000); + if (hasFfmpeg) { + const artifact = await prisma.artifact.findFirst({ + where: { id: variant.previewArtifactId ?? "", spaceId: actor.spaceId }, + }); + expect(artifact?.mimeType).toBe("video/mp4"); + } + } + expect(variants[0]?.template.templateId).toBe("intro_elegant_fade"); + }); + }); + + describe.skipIf(!hasFfmpeg)("thumbnails, publications and bundles", () => { + async function finishedRender( + actor: MediaActor, + input: { profile: PlatformProfile; quality?: "draft" | "final"; projectId?: string }, + ) { + const assetIds = input.projectId ? [] : [(await seedLibrary(actor)).assetA]; + const projectId = + input.projectId ?? + ( + await repos.createProject(actor.spaceId, actor.userId, { + title: "Lake Day Launch", + goal: "teaser", + profileIds: [input.profile.id], + assetIds, + }) + ).id; + const vertical = input.profile.video.aspectRatios.includes("9:16"); + const plan = await repos.createPlanVersion(actor.spaceId, { + projectId, + profileId: input.profile.id, + edl: { + edlVersion: 1, + output: { + profileId: input.profile.id, + width: input.profile.video.width, + height: input.profile.video.height, + fps: 30, + durationTargetMs: 4000, + durationHardMinMs: 1000, + durationHardMaxMs: 60_000, + }, + tracks: { + video: [ + { + id: "c1", + assetId: assetIds[0] ?? "a", + inMs: 0, + outMs: 4000, + atMs: 0, + reframe: "none", + }, + ], + }, + metadata: { title: "Lake day", aiAltered: true, aiAlterations: ["inpaint"] }, + }, + createdBy: "agent", + rationale: null, + }); + const render = await repos.createRender(actor.spaceId, { + planId: plan.id, + quality: input.quality ?? "final", + createdBy: actor.userId, + }); + const dir = path.join(mediaRoot, actor.spaceId, "renders", render.id); + await mkdir(dir, { recursive: true }); + await ffmpeg([ + "-f", + "lavfi", + "-i", + `testsrc2=s=${vertical ? "180x320" : "320x180"}:r=30:d=4`, + "-f", + "lavfi", + "-i", + "sine=frequency=440:duration=4", + "-c:v", + "libx264", + "-preset", + "ultrafast", + "-pix_fmt", + "yuv420p", + "-g", + "30", + "-c:a", + "aac", + "-shortest", + path.join(dir, "video.mp4"), + ]); + await ffmpeg([ + "-i", + path.join(dir, "video.mp4"), + "-frames:v", + "1", + path.join(dir, "poster.png"), + ]); + const srt = "1\n00:00:00,000 --> 00:00:02,000\nHello\n"; + await writeFile(path.join(dir, "subtitles.srt"), srt); + await repos.claimRender(actor.spaceId, render.id); + await repos.updateRender(actor.spaceId, render.id, { + status: "done", + progress: 1, + gateReport: gatePassed(input.profile.id), + outputs: [ + { + kind: "video", + artifactId: null, + fileName: "video.mp4", + sizeBytes: (await stat(path.join(dir, "video.mp4"))).size, + }, + { kind: "poster", artifactId: null, fileName: "poster.png", sizeBytes: 1 }, + { + kind: "subtitles_srt", + artifactId: null, + fileName: "subtitles.srt", + sizeBytes: srt.length, + }, + ], + }); + return { render, projectId }; + } + + it("creates three EXIF-free thumbnail variants", async () => { + const actor = await newActor(); + const { render } = await finishedRender(actor, { profile: SHORT }); + const thumbs = await createMediaMarketingService(deps).thumbnails(actor, { + renderId: render.id, + count: 3, + }); + expect(thumbs.map((thumb) => thumb.fileName)).toEqual([ + "thumb-a.png", + "thumb-b.png", + "thumb-c.png", + ]); + const stored = await repos.getRender(actor.spaceId, render.id); + expect(stored.outputs.filter((output) => output.kind === "thumbnail")).toHaveLength(3); + const file = await readFile( + path.join(mediaRoot, actor.spaceId, "renders", render.id, "thumbs", "thumb-a.png"), + ); + for (const chunk of ["eXIf", "tEXt", "iTXt", "zTXt"]) { + expect(file.includes(Buffer.from(chunk, "ascii"))).toBe(false); + } + }); + + it("refuses drafts and builds a complete bundle with tracking and checklist", async () => { + const actor = await newActor(); + const marketing = createMediaMarketingService(deps); + const draft = await finishedRender(actor, { profile: SHORT, quality: "draft" }); + const base = { + destination: "bundle", + targetUrl: "https://example.com/full-video", + utm: null, + title: "Lake day", + caption: "A calm morning at the lake", + tags: ["lake", "#morning", "calm", "extra"], + thumbnailVariant: null, + scheduledAt: null, + }; + await expect( + marketing.createPublication(actor, { ...base, renderId: draft.render.id }), + ).rejects.toThrow(/final render/); + + const { render } = await finishedRender(actor, { + profile: SHORT, + projectId: draft.projectId, + }); + await marketing.thumbnails(actor, { renderId: render.id, count: 3 }); + const publication = await marketing.createPublication(actor, { + ...base, + renderId: render.id, + thumbnailVariant: "thumb-b", + }); + const tracking = new URL(publication.trackingUrl ?? ""); + expect(tracking.searchParams.get("utm_source")).toBe(SHORT.id); + expect(tracking.searchParams.get("utm_medium")).toBe("video-9x16"); + expect(tracking.searchParams.get("utm_campaign")).toBe("lake-day-launch"); + expect(tracking.searchParams.get("utm_content")).toBe("thumb-b"); + + const bundled = await marketing.bundle(actor, publication.id); + expect(bundled.status).toBe("bundled"); + expect(bundled.bundleArtifactId).not.toBeNull(); + const dir = path.join(mediaRoot, bundleRelativeDir(actor.spaceId, publication.id)); + expect((await readdir(dir)).sort()).toEqual( + [ + "AI-DISCLOSURE.txt", + "UPLOAD-CHECKLIST.md", + "bundle.zip", + "caption.txt", + "metadata.json", + "poster.png", + "subtitles.srt", + "thumb-a.png", + "thumb-b.png", + "thumb-c.png", + "video.mp4", + ].sort(), + ); + const checklist = await readFile(path.join(dir, "UPLOAD-CHECKLIST.md"), "utf8"); + expect(checklist).toContain("AI-generated or altered content label"); + expect(checklist).toContain("profile bio"); + const caption = await readFile(path.join(dir, "caption.txt"), "utf8"); + expect(caption).not.toContain("https://"); + expect(caption.match(/#/g)).toHaveLength(3); + const metadata = JSON.parse(await readFile(path.join(dir, "metadata.json"), "utf8")); + expect(metadata).toMatchObject({ aiAltered: true, aiAlterations: ["inpaint"] }); + expect( + (await readFile(path.join(dir, "poster.png"))).equals( + await readFile(path.join(dir, "thumb-b.png")), + ), + ).toBe(true); + }); + + it("schedules the publish job, bundles at the time, and reminds instead of uploading paid videos", async () => { + const actor = await newActor(); + const marketing = createMediaMarketingService(deps); + const { render } = await finishedRender(actor, { profile: PAID }); + const at = new Date(Date.now() + 3_600_000).toISOString(); + const publication = await marketing.createPublication(actor, { + renderId: render.id, + destination: "bundle", + targetUrl: null, + utm: null, + title: "Full release", + caption: null, + tags: [], + thumbnailVariant: null, + scheduledAt: at, + }); + expect(publication.status).toBe("scheduled"); + const job = jobs.find((entry) => entry.replaceKey === `media.publish:${publication.id}`); + expect(job?.availableAt?.toISOString()).toBe(at); + + const publish = vi.spyOn(deps.publisher, "publish"); + const remind = vi.fn(async () => {}); + const handlers = createMediaMarketingJobHandlers(deps, marketing, { remind }); + await handlers["media.publish"]({ publicationId: publication.id }); + expect(publish).not.toHaveBeenCalled(); + expect(remind).toHaveBeenCalledTimes(1); + const after = await repos.getPublication(actor.spaceId, publication.id); + expect(after.status).toBe("bundled"); + const dir = path.join(mediaRoot, bundleRelativeDir(actor.spaceId, publication.id)); + const files = await readdir(dir); + expect(files).toContain("preview.mp4"); + const checklist = await readFile(path.join(dir, "UPLOAD-CHECKLIST.md"), "utf8"); + expect(checklist).toContain("age-restricted"); + + const canceled = await marketing.cancelPublication(actor, publication.id); + expect(canceled.status).toBe("canceled"); + expect(canceledJobs).toContain(`media.publish:${publication.id}`); + await handlers["media.publish"]({ publicationId: publication.id }); + expect(remind).toHaveBeenCalledTimes(1); + publish.mockRestore(); + }); + }); + + describe("results", () => { + it("imports CSV through a saved mapping without double counting and learns from 20 publications", async () => { + const actor = await newActor(); + const { project, segments, assetA } = await seedLibrary(actor); + const marketing = createMediaMarketingService(deps); + const strong = segments.find((s) => s.assetId === assetA && s.startMs === 0)!; + const weak = segments.find((s) => s.assetId === assetA && s.startMs === 16_000)!; + const renders: string[] = []; + for (const segment of [strong, weak]) { + const plan = await repos.createPlanVersion(actor.spaceId, { + projectId: project.id, + profileId: SHORT.id, + edl: { + edlVersion: 1, + output: { + profileId: SHORT.id, + width: 1080, + height: 1920, + fps: 30, + durationTargetMs: 3000, + durationHardMinMs: 1000, + durationHardMaxMs: 60_000, + }, + tracks: { + video: [ + { + id: "c1", + assetId: assetA, + inMs: segment.startMs, + outMs: segment.startMs + 3000, + atMs: 0, + reframe: "none", + segmentId: segment.id, + }, + ], + }, + metadata: {}, + }, + createdBy: "agent", + rationale: null, + }); + const render = await repos.createRender(actor.spaceId, { + planId: plan.id, + quality: "final", + createdBy: actor.userId, + }); + await repos.claimRender(actor.spaceId, render.id); + await repos.updateRender(actor.spaceId, render.id, { status: "done", progress: 1 }); + renders.push(render.id); + } + const publicationIds: string[] = []; + for (let i = 0; i < 20; i++) { + const publication = await repos.createPublication(actor.spaceId, actor.userId, { + renderId: renders[i % 2]!, + destination: "bundle", + targetUrl: null, + trackingUrl: null, + utm: null, + title: null, + caption: null, + tags: [], + thumbnailVariant: null, + scheduledAt: null, + }); + publicationIds.push(publication.id); + } + const mapping = await repos.saveMetricMapping(actor.spaceId, { + name: "Shorts export", + delimiter: ";", + columns: { + publication: "Post", + observedAt: "Date", + views: "Views", + clicks: "Clicks", + conversions: "Sales", + revenue: "Revenue", + }, + publicationKey: "id", + revenueUnit: "major", + defaultCurrency: "EUR", + }); + const csv = (ids: string[]) => + [ + "Post;Date;Views;Clicks;Sales;Revenue", + ...ids.map((id, i) => { + const strongPost = i % 2 === 0; + return `${id};2026-09-0${(i % 9) + 1};1000;${strongPost ? 120 : 40};${strongPost ? 30 : 2};${strongPost ? "299,70" : "19,98"}`; + }), + "unknown-post;2026-09-01;10;1;0;0", + ].join("\n"); + + const first19 = await marketing.importMetricsCsv(actor, { + mappingId: mapping.id as string, + csv: csv(publicationIds.slice(0, 19)), + }); + expect(first19.imported).toBe(19); + expect(first19.skipped).toEqual([{ row: 21, reason: "publication not found" }]); + expect(await repos.getTeaserModel(actor.spaceId)).toBeNull(); + + const again = await marketing.importMetricsCsv(actor, { + mappingId: mapping.id as string, + csv: csv(publicationIds.slice(0, 19)), + }); + expect(again.imported).toBe(19); + const funnel = await repos.funnel(actor.spaceId, project.id); + expect(funnel.reduce((sum, row) => sum + row.views, 0)).toBe(19_000); + expect(await refitTeaserModel(deps, actor.spaceId)).toBeNull(); + + await recordMetric(deps, actor, { + publicationId: publicationIds[19]!, + observedAt: "2026-09-10T00:00:00.000Z", + views: 1000, + clicks: 40, + conversions: 2, + revenueCents: 1998, + currency: "EUR", + }); + const model = await repos.getTeaserModel(actor.spaceId); + expect(model?.sampleCount).toBe(20); + const totals = funnel.find((row) => row.publicationId === publicationIds[0]); + expect(totals).toMatchObject({ + views: 1000, + clicks: 120, + conversions: 30, + revenueCents: 29_970, + }); + await resetTeaserModel(deps, actor); + expect(await repos.getTeaserModel(actor.spaceId)).toBeNull(); + }); + }); +}); diff --git a/packages/adapters/src/media-marketing.test.ts b/packages/adapters/src/media-marketing.test.ts new file mode 100644 index 0000000..9820519 --- /dev/null +++ b/packages/adapters/src/media-marketing.test.ts @@ -0,0 +1,350 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import type { Edl, MediaSegment, PlatformProfile } from "@rakazo/contracts"; +import { EdlSchema, PlatformProfileSchema } from "@rakazo/contracts"; +import type { MediaRepos } from "@rakazo/db"; +import type { ProfileSet, ScoredSegmentInput } from "@rakazo/media"; +import { escapeFilterText } from "@rakazo/media"; +import { describe, expect, it } from "vitest"; +import { isCubeLut } from "./media-brand.js"; +import type { MediaModelClient, MediaServiceDeps } from "./media-context.js"; +import { generateCopy, truncateText } from "./media-marketing-copy.js"; +import { applyRanking, defaultCtaText, showsProtectedDetail } from "./media-marketing-plan.js"; +import { + measureGrayFrame, + thumbnailCandidateTimes, + titleOverlayFilter, +} from "./media-marketing-thumbnails.js"; +import { planFeatures } from "./media-metrics.js"; +import { assertPublishable } from "./media-publish.js"; +import { BundleMediaPublisher } from "./media-publisher-bundle.js"; + +function profile(text: Record, overrides: Record = {}) { + return PlatformProfileSchema.parse({ + id: "example-short-vertical", + name: "Example short vertical", + kind: "free_social", + reviewedAt: "2026-09-01", + video: { + aspectRatios: ["9:16"], + width: 1080, + height: 1920, + fps: 30, + codec: "h264", + maxBitrateKbps: 8000, + maxFileSizeMb: 500, + }, + duration: { hardMinMs: 1000, hardMaxMs: 60_000 }, + safeZones: { topPct: 10, bottomPct: 20, leftPct: 5, rightPct: 12 }, + captions: { recommended: true }, + audio: {}, + text: { captionMaxChars: 150, hashtagLimit: 3, linkPolicy: "bio_only", ...text }, + content: { thresholds: {} }, + disclosure: { aiAlteredLabelRequired: true }, + consent: { requirePerformerConsent: false, requireAgeVerification: false }, + export: {}, + ...overrides, + }); +} + +function edl(overrides: Partial = {}): Edl { + return EdlSchema.parse({ + edlVersion: 1, + output: { + profileId: "example-short-vertical", + width: 1080, + height: 1920, + fps: 30, + durationTargetMs: 9000, + durationHardMinMs: 1000, + durationHardMaxMs: 60_000, + }, + tracks: { + video: [ + { + id: "c1", + assetId: "a1", + inMs: 0, + outMs: 3000, + atMs: 0, + reframe: "none", + segmentId: "s1", + }, + { id: "c2", assetId: "a1", inMs: 6000, outMs: 12_000, atMs: 3000, reframe: "none" }, + ], + }, + metadata: { title: "Lake day", tags: ["lake"] }, + ...overrides, + }); +} + +function segment(id: string, startMs: number, endMs: number, extra: Partial = {}) { + return { + id, + assetId: "a1", + kind: "shot", + startMs, + endMs, + scores: {}, + teaserScore: null, + transcriptExcerpt: null, + labels: [], + paidOnly: false, + ...extra, + } as MediaSegment; +} + +describe("teaser plans", () => { + it("ends teasers on a call to action that follows the link policy", () => { + expect(defaultCtaText("teaser", profile({ linkPolicy: "bio_only" }))).toBe("Link in bio"); + expect(defaultCtaText("teaser", profile({ linkPolicy: "allowed" }))).toBe("Link below"); + expect(defaultCtaText("teaser", profile({ linkPolicy: "forbidden" }))).not.toMatch(/link/i); + expect(defaultCtaText("full_release", profile({}))).toBeNull(); + }); + + it("re-weights by the model order and leaves paid-only segments alone", () => { + const input = (id: string, paidOnly = false): ScoredSegmentInput => ({ + id, + assetId: "a1", + startMs: 0, + endMs: 1000, + capturedAt: null, + features: { motion: 1, sharpness: 0.5 }, + paidOnly, + transcript: null, + }); + const ranked = applyRanking([input("a"), input("b"), input("c"), input("p", true)], ["b", "a"]); + const byId = new Map(ranked.map((s) => [s.id, s.features])); + expect(byId.get("b")).toEqual({ motion: 1, sharpness: 0.5 }); + expect(byId.get("a")).toEqual({ motion: 0.75, sharpness: 0.375 }); + expect(byId.get("c")).toEqual({ motion: 0.5, sharpness: 0.25 }); + expect(byId.get("p")).toEqual({ motion: 1, sharpness: 0.5 }); + }); + + it("keeps protected faces and treated marks away from the model", () => { + const track = (personId: string | null) => ({ + assetId: "a1", + startMs: 1000, + endMs: 2000, + personId, + }); + const ctx = (tracks: unknown[], marks: unknown[] = []) => + ({ faceTracks: tracks, marks }) as Parameters[0]; + const span = { assetId: "a1", startMs: 0, endMs: 1500 }; + expect(showsProtectedDetail(ctx([track("p1")]), new Set(), span)).toBe(false); + expect(showsProtectedDetail(ctx([track("p1")]), new Set(["p1"]), span)).toBe(true); + expect(showsProtectedDetail(ctx([track(null)]), new Set(["p2"]), span)).toBe(true); + expect( + showsProtectedDetail(ctx([track("p1")]), new Set(), { ...span, startMs: 2000, endMs: 3000 }), + ).toBe(false); + const tattoo = { treatment: "blur", assetId: null, personId: "p1" }; + expect(showsProtectedDetail(ctx([track("p1")], [tattoo]), new Set(), span)).toBe(true); + expect( + showsProtectedDetail(ctx([track("p1")], [{ ...tattoo, treatment: "none" }]), new Set(), span), + ).toBe(false); + const plate = { treatment: "cover", assetId: "a1", personId: null }; + expect(showsProtectedDetail(ctx([], [plate]), new Set(), span)).toBe(true); + }); + + it("learns from the clip-length-weighted features of the segments a plan used", () => { + const features = planFeatures(edl(), [ + segment("s1", 0, 3000, { scores: { motion: 1 } }), + segment("s2", 6000, 12_000, { scores: { motion: 0.25 }, transcriptExcerpt: "Wow!" }), + segment("s3", 15_000, 18_000, { scores: { motion: 0 } }), + ]); + expect(features?.motion).toBeCloseTo((1 * 3000 + 0.25 * 6000) / 9000); + expect(features?.speechHook).toBeGreaterThan(0); + expect(planFeatures(edl(), [])).toBeNull(); + }); +}); + +describe("thumbnails", () => { + it("measures sharpness, contrast and thirds of a grey frame", () => { + const width = 30; + const height = 30; + const flat = new Uint8Array(width * height).fill(128); + const checker = Uint8Array.from({ length: width * height }, (_, i) => + (i % width) % 2 === Math.floor(i / width) % 2 ? 255 : 0, + ); + expect(measureGrayFrame(flat, width, height)).toEqual({ + laplacianVariance: 0, + contrast: 0, + thirds: 0, + }); + const busy = measureGrayFrame(checker, width, height); + expect(busy.laplacianVariance).toBeGreaterThan(1000); + expect(busy.contrast).toBeGreaterThan(100); + }); + + it("samples the main program after the intro and just after each cut", () => { + const withIntro = edl({ intro: { templateId: "intro_glitch", params: {}, durationMs: 2000 } }); + const times = thumbnailCandidateTimes(withIntro, 11_000); + expect(times.length).toBeGreaterThanOrEqual(24); + expect(Math.min(...times)).toBeGreaterThan(2000); + expect(Math.max(...times)).toBeLessThan(11_000); + expect(times).toContain(2000 + 3000 + 200); + }); + + it("escapes the title and falls back to neutral colours", () => { + const hostile = "Hi': drawtext=text='x' ;[out] %{pts}"; + const filter = titleOverlayFilter({ + text: hostile, + width: 1080, + height: 1920, + profile: profile({}), + kit: null, + fontFile: "/fonts/My Font:Bold.ttf", + }); + expect(filter).toContain(`text=${escapeFilterText(hostile)}`); + expect(filter).not.toContain(hostile); + expect(filter).toContain("fontcolor=white"); + expect(filter).toContain(`y=${Math.round(1920 * 0.14)}`); + }); +}); + +describe("copy", () => { + const render = { + id: "r1", + planId: "p1", + projectId: "j1", + profileId: "example-short-vertical", + }; + + function copyDeps(reply: unknown, text: Record = {}) { + const short = profile({ + titleMaxChars: 20, + bannedPatterns: [{ pattern: "guaranteed", reason: "false promise" }], + ...text, + }); + const profiles: ProfileSet = { + profiles: new Map([[short.id, { profile: short, origin: "builtin" as const }]]), + errors: [], + }; + const calls: Array[0]> = []; + const deps = { + repos: { + getRender: async () => render, + getPlan: async () => ({ edl: edl() }), + getProjectRecord: async () => ({ title: "Lake Day Launch" }), + listSegments: async () => [ + segment("s1", 0, 3000, { transcriptExcerpt: "Welcome back. Today we film at the lake." }), + ], + } as unknown as MediaRepos, + profiles: async () => profiles, + model: { + async generate(input) { + calls.push(input); + return reply === null ? null : input.schema.parse(reply); + }, + } as MediaModelClient, + } as unknown as MediaServiceDeps; + return { deps, calls }; + } + const actor = { spaceId: "s", userId: "u" }; + + it("falls back to the plan metadata without a model", async () => { + const { deps, calls } = copyDeps(null); + const copy = await generateCopy(deps, actor, { renderId: "r1", language: "de" }); + expect(calls[0]?.instructions).toContain('language "de"'); + expect(calls[0]?.prompt).toContain("Today we film at the lake"); + expect(copy).toMatchObject({ + title: "Lake day", + hook: "Welcome back.", + caption: "Lake day", + hashtags: ["#lake"], + issues: [], + }); + }); + + it("cuts to the profile limits and reports banned patterns and links in every field", async () => { + const { deps } = copyDeps({ + title: "The calmest lake morning you will ever see", + hook: "Guaranteed calm", + caption: `See https://example.com/video now ${"x".repeat(200)}`, + hashtags: ["lake", "calm", "morning", "travel", "nature"], + description: "More at https://example.com", + altText: "A lake at sunrise", + }); + const copy = await generateCopy(deps, actor, { renderId: "r1", language: "en" }); + expect([...copy.title].length).toBeLessThanOrEqual(20); + expect([...copy.caption].length).toBeLessThanOrEqual(150); + expect(copy.hashtags).toEqual(["#lake", "#calm", "#morning"]); + const codes = copy.issues.map((issue) => `${issue.code}:${issue.message}`); + expect(codes.some((code) => code.startsWith("link_bio_only:caption"))).toBe(true); + expect(codes.some((code) => code.startsWith("link_bio_only:hook or description"))).toBe(true); + expect(codes.some((code) => code.startsWith("banned_pattern:hook or description"))).toBe(true); + expect(copy.chapters).toBeUndefined(); + }); + + it("rejects malformed language codes", async () => { + const { deps } = copyDeps(null); + await expect( + generateCopy(deps, actor, { renderId: "r1", language: "en; drop" }), + ).rejects.toThrow(/language/); + }); + + it("cuts long text at a word boundary", () => { + expect(truncateText("one two three four", 15)).toBe("one two three"); + expect(truncateText("one two three four", 12)).toBe("one two thre"); + expect(truncateText("short", 12)).toBe("short"); + }); +}); + +describe("publishing", () => { + it("publishes only final renders whose gate passed", () => { + const gate = { passed: true } as never; + expect(() => + assertPublishable({ status: "done", quality: "final", gateReport: gate }), + ).not.toThrow(); + expect(() => assertPublishable({ status: "done", quality: "draft", gateReport: gate })).toThrow( + /final/, + ); + expect(() => + assertPublishable({ status: "blocked", quality: "final", gateReport: gate }), + ).toThrow(/finished/); + expect(() => assertPublishable({ status: "done", quality: "final", gateReport: null })).toThrow( + /gate/, + ); + expect(() => + assertPublishable({ + status: "done", + quality: "final", + gateReport: { passed: false } as never, + }), + ).toThrow(/gate/); + }); + + it("reports a bundle only when the files are really there", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "media-bundle-")); + try { + const publisher = new BundleMediaPublisher(); + const request = { + bundleDir: dir, + profile: profile({}) as PlatformProfile, + publicationId: "pub1", + title: null, + caption: null, + tags: [], + }; + await expect(publisher.publish(request)).rejects.toThrow(/missing video.mp4/); + await writeFile(path.join(dir, "video.mp4"), "v"); + await expect(publisher.publish(request)).rejects.toThrow(/missing bundle.zip/); + await writeFile(path.join(dir, "bundle.zip"), "z"); + expect(await publisher.publish(request)).toEqual({ status: "bundled", externalUrl: null }); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); + +describe("brand kits", () => { + it("accepts only complete 3D .cube LUTs", () => { + const rows = (n: number) => Array.from({ length: n }, () => "0.1 0.2 0.3").join("\n"); + expect(isCubeLut(`TITLE "look"\nLUT_3D_SIZE 2\n${rows(8)}\n`)).toBe(true); + expect(isCubeLut(`LUT_3D_SIZE 2\n${rows(7)}\n`)).toBe(false); + expect(isCubeLut(`LUT_1D_SIZE 2\n${rows(2)}\n`)).toBe(false); + expect(isCubeLut("LUT_3D_SIZE 2\n0 0 zero\n")).toBe(false); + expect(isCubeLut("not a lut")).toBe(false); + }); +}); diff --git a/packages/adapters/src/media-zip.test.ts b/packages/adapters/src/media-zip.test.ts new file mode 100644 index 0000000..c3ab600 --- /dev/null +++ b/packages/adapters/src/media-zip.test.ts @@ -0,0 +1,108 @@ +import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { crc32 } from "node:zlib"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { isSafeZipEntryName, writeStoreZip } from "./media-zip.js"; + +let dir: string; + +beforeEach(async () => { + dir = await mkdtemp(path.join(tmpdir(), "media-zip-")); +}); + +afterEach(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +/** Read a store-only archive through its central directory, checking every CRC. */ +function readStoreZip(bytes: Buffer): Array<{ name: string; data: Buffer; mode: number }> { + const end = bytes.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06])); + expect(end).toBeGreaterThan(0); + const count = bytes.readUInt16LE(end + 10); + let at = bytes.readUInt32LE(end + 16); + const entries: Array<{ name: string; data: Buffer; mode: number }> = []; + for (let index = 0; index < count; index++) { + expect(bytes.readUInt32LE(at)).toBe(0x02014b50); + const crc = bytes.readUInt32LE(at + 16); + const size = bytes.readUInt32LE(at + 20); + const nameLength = bytes.readUInt16LE(at + 28); + const mode = bytes.readUInt32LE(at + 38) >>> 16; + const offset = bytes.readUInt32LE(at + 42); + const name = bytes.subarray(at + 46, at + 46 + nameLength).toString("ascii"); + expect(bytes.readUInt32LE(offset)).toBe(0x04034b50); + expect(bytes.readUInt16LE(offset + 8)).toBe(0); + const localName = bytes.readUInt16LE(offset + 26); + const start = offset + 30 + localName + bytes.readUInt16LE(offset + 28); + const data = bytes.subarray(start, start + size); + expect(crc32(data) >>> 0).toBe(crc); + entries.push({ name, data, mode }); + at += 46 + nameLength; + } + return entries; +} + +describe("writeStoreZip", () => { + it("stores flat entries byte for byte and deterministically", async () => { + const video = path.join(dir, "v"); + const notes = path.join(dir, "n"); + await writeFile(video, Buffer.alloc(200_000, 7)); + await writeFile(notes, "# Upload checklist\n"); + const entries = [ + { name: "video.mp4", path: video }, + { name: "UPLOAD-CHECKLIST.md", path: notes }, + ]; + const first = path.join(dir, "a.zip"); + const size = await writeStoreZip(first, entries); + expect((await stat(first)).size).toBe(size); + const read = readStoreZip(await readFile(first)); + expect(read.map((entry) => entry.name)).toEqual(["video.mp4", "UPLOAD-CHECKLIST.md"]); + expect(read[0]?.data.equals(Buffer.alloc(200_000, 7))).toBe(true); + expect(read[1]?.data.toString()).toBe("# Upload checklist\n"); + expect(read[0]?.mode).toBe(0o100644); + + const second = path.join(dir, "b.zip"); + await writeStoreZip(second, entries); + expect((await readFile(second)).equals(await readFile(first))).toBe(true); + }); + + it("refuses names that could escape the extraction folder", async () => { + const file = path.join(dir, "x"); + await writeFile(file, "x"); + for (const name of [ + "../evil.sh", + "..", + "/etc/passwd", + "a/b.txt", + "a\\b.txt", + ".hidden", + "C:evil", + "name\0.txt", + "", + `${"a".repeat(101)}`, + ]) { + expect(isSafeZipEntryName(name), name).toBe(false); + await expect(writeStoreZip(path.join(dir, "out.zip"), [{ name, path: file }])).rejects.toThrow( + /unsafe zip entry/, + ); + } + expect(isSafeZipEntryName("thumb-a.png")).toBe(true); + expect(isSafeZipEntryName("UPLOAD-CHECKLIST.md")).toBe(true); + }); + + it("rejects duplicate names and leaves no partial archive behind", async () => { + const file = path.join(dir, "x"); + await writeFile(file, "x"); + const out = path.join(dir, "dup.zip"); + await expect( + writeStoreZip(out, [ + { name: "caption.txt", path: file }, + { name: "caption.txt", path: file }, + ]), + ).rejects.toThrow(/duplicate/); + await expect( + writeStoreZip(out, [{ name: "missing.txt", path: path.join(dir, "missing") }]), + ).rejects.toThrow(); + await expect(stat(out)).rejects.toThrow(); + }); +});